Bases de datos

Migración de MySQL a PostgreSQL: guía paso a paso sin tiempo de inactividad para sistemas en producción

Ruslan Ismailov Publicado 14 min de lectura
M

Introducción: por qué las empresas migran de MySQL a PostgreSQL en 2026

En 2026, PostgreSQL ocupa con firmeza el primer lugar entre los sistemas de gestión de bases de datos relacionales de código abierto según el índice de popularidad de DB-Engines. Las razones para migrar de MySQL a PostgreSQL son tanto técnicas como estratégicas.

En primer lugar, PostgreSQL ofrece un sistema de tipos más rico: JSONB nativo con indexación, arrays, tipos de rango, búsqueda de texto completo y extensibilidad mediante extensiones (PostGIS, pgvector, TimescaleDB). MySQL queda claramente por detrás en este aspecto.

En segundo lugar, PostgreSQL sigue estrictamente el estándar SQL. Esto reduce las sorpresas con consultas complejas: funciones de ventana, CTEs recursivos, LATERAL JOIN — todo funciona de manera predecible. MySQL históricamente ha tenido un soporte débil para estas construcciones y algunos comportamientos predeterminados extraños (como el modo estricto desactivado por defecto en versiones antiguas).

En tercer lugar, la política de licencias de Oracle en torno a MySQL lleva a las empresas a reflexionar sobre los riesgos del vendor lock-in. PostgreSQL está licenciado bajo su propia licencia libre sin restricciones para uso comercial.

Por último, los proveedores de nube (AWS Aurora PostgreSQL, Google AlloyDB, Azure Flexible Server) invierten activamente en el ecosistema de PostgreSQL, ofreciendo soluciones gestionadas más maduras.

La principal dificultad de la migración no es la diferencia de sintaxis, sino la necesidad de realizarla sin interrumpir el servicio. Los sistemas en producción no pueden permitirse ni una hora de tiempo de inactividad. En este artículo repasaremos el ciclo completo de migración zero-downtime de MySQL a PostgreSQL: desde la evaluación del alcance hasta el cambio final de tráfico.

Evaluación del alcance de la migración

Antes de escribir el primer comando, es necesario realizar una auditoría de la base de datos actual. Esta es la etapa más importante y, con frecuencia, la más subestimada.

Qué inventariar

  • Tipos de datos: TINYINT(1) se usa en MySQL como tipo booleano — en PostgreSQL existe un BOOLEAN nativo. DATETIME vs TIMESTAMP WITH TIME ZONE. ENUM está implementado de forma diferente en MySQL y PostgreSQL. UNSIGNED INT no existe en PostgreSQL.
  • Procedimientos almacenados y funciones: los procedimientos de MySQL están escritos en un dialecto incompatible con PL/pgSQL. Cada procedimiento deberá reescribirse manualmente.
  • Triggers: la sintaxis es fundamentalmente diferente. En PostgreSQL, un trigger invoca una función separada en lugar de contener la lógica dentro de sí mismo.
  • Sintaxis específica: GROUP BY en MySQL es menos estricto, INSERT ... ON DUPLICATE KEY UPDATE se reemplaza por INSERT ... ON CONFLICT, LIMIT x, y se reemplaza por LIMIT y OFFSET x.
  • Codificación: utf8 en MySQL es en realidad UTF-8 de tres bytes. El verdadero UTF-8 de cuatro bytes es utf8mb4. PostgreSQL usa UTF-8 estándar.
  • Collation: las reglas de ordenación de cadenas pueden diferir, lo que afecta a los índices y al orden de los resultados.

Para estimar rápidamente el volumen de trabajo, ejecute en MySQL:

-- Obtener la lista de todas las tablas con tipos de datos\nSELECT TABLE_NAME, COLUMN_NAME, DATA_TYPE, COLUMN_TYPE\nFROM information_schema.COLUMNS\nWHERE TABLE_SCHEMA = 'your_db'\nORDER BY TABLE_NAME, ORDINAL_POSITION;\n\n-- Procedimientos almacenados\nSELECT ROUTINE_NAME, ROUTINE_TYPE\nFROM information_schema.ROUTINES\nWHERE ROUTINE_SCHEMA = 'your_db';

Tras la auditoría, elabore una tabla de riesgos: qué objetos requieren trabajo manual, cuáles se migran automáticamente y cuáles conllevan mayor riesgo de regresiones.

Estrategia de migración zero-downtime

Para sistemas en producción existen dos estrategias principales que se pueden combinar.

Dual-write (escritura doble)

La aplicación escribe simultáneamente en ambas bases de datos — MySQL y PostgreSQL. Las lecturas se realizan desde MySQL (la base antigua). Los datos se acumulan en PostgreSQL. Tras la verificación, las lecturas se redirigen a PostgreSQL y luego se desactiva la escritura en MySQL.

Ventajas: control total a nivel de aplicación, rollback sencillo. Desventajas: requiere modificar el código de la aplicación, existe riesgo de desincronización si se producen errores al escribir en una de las bases.

CDC (Change Data Capture)

Este enfoque se basa en leer el log binario de MySQL (binlog) y aplicar los cambios a PostgreSQL en tiempo real. No requiere modificar el código de la aplicación en la primera etapa.

Stack típico de migración con CDC:

  1. Realizar el snapshot inicial: transferir los datos de MySQL a PostgreSQL mediante pgLoader o AWS DMS.
  2. Iniciar el agente CDC (Debezium), que lee el binlog de MySQL y publica eventos en Kafka.
  3. Un consumidor de Kafka aplica los cambios a PostgreSQL.
  4. Una vez que PostgreSQL ha alcanzado a MySQL en cuanto a datos, redirigir el tráfico.

Estrategia combinada: utilice CDC para la sincronización de datos + dual-write en la etapa final como seguro antes del cut-over.

Herramientas de migración: comparativa

pgLoader

pgLoader es una herramienta especializada para cargar datos en PostgreSQL. Convierte tipos de datos al vuelo y trabaja rápidamente gracias al protocolo COPY.

LOAD DATABASE\n  FROM mysql://user:password@mysql-host/source_db\n  INTO postgresql://user:password@pg-host/target_db\n\nWITH include drop, create tables,\n     create indexes, reset sequences,\n     workers = 8, concurrency = 1\n\nSET work_mem to '128MB',\n    maintenance_work_mem to '512MB'\n\nALTER SCHEMA 'source_db' RENAME TO 'public';

pgLoader es ideal para la carga inicial, pero no está diseñado para CDC en vivo.

AWS DMS (Database Migration Service)

Servicio gestionado de Amazon. Soporta tanto carga completa como modo CDC desde MySQL a PostgreSQL (incluido Aurora). Es sencillo de configurar a través de la interfaz, pero tiene limitaciones: no migra procedimientos almacenados y algunos tipos de datos requieren configuración manual del mapeo. Es una buena opción si ya trabaja en el ecosistema de AWS.

Debezium

Plataforma CDC de código abierto basada en Apache Kafka Connect. Lee el binlog de MySQL y publica eventos de cambio. Es la herramienta más flexible y probada en producción para la sincronización continua.

Scripts personalizados

Justificados solo para bases de datos pequeñas o lógicas de transformación muy específicas. Para sistemas de producción grandes, el riesgo de errores es demasiado alto.

Recomendación: para la mayoría de los proyectos, la combinación óptima es pgLoader (carga inicial) + Debezium + Kafka (CDC). Si el proyecto está en AWS, considere DMS como alternativa a Debezium.

Migración del esquema: diferencias clave

AUTO_INCREMENT vs SEQUENCES

En MySQL, la clave primaria con autoincremento se declara así:

CREATE TABLE orders (\n  id INT UNSIGNED NOT NULL AUTO_INCREMENT,\n  PRIMARY KEY (id)\n);

En PostgreSQL se utiliza el tipo SERIAL o el más moderno GENERATED ALWAYS AS IDENTITY:

CREATE TABLE orders (\n  id INTEGER GENERATED ALWAYS AS IDENTITY PRIMARY KEY\n);\n\n-- O mediante una secuencia independiente:\nCREATE SEQUENCE orders_id_seq;\nCREATER TABLE orders (\n  id INTEGER NOT NULL DEFAULT nextval('orders_id_seq') PRIMARY KEY\n);

Tras la carga inicial, no olvide reiniciar la secuencia al valor correcto:

SELECT setval('orders_id_seq', (SELECT MAX(id) FROM orders));

JSON vs JSONB

MySQL almacena JSON como texto con validación. PostgreSQL ofrece dos tipos: JSON (almacena como texto, analiza en cada consulta) y JSONB (formato binario, soporta índices GIN). Para producción, use siempre JSONB:

-- MySQL\nCREATE TABLE events (\n  payload JSON\n);\n\n-- PostgreSQL\nCREATER TABLE events (\n  payload JSONB\n);\n\n-- Creación de índice GIN para búsqueda rápida en JSONB\nCREATE INDEX idx_events_payload ON events USING GIN (payload);\n\n-- Consulta sobre JSONB\nSELECT * FROM events WHERE payload @> '{\"type\": \"purchase\"}';

Otras diferencias críticas en tipos de datos

  • TINYINT(1) → BOOLEAN
  • DATETIME → TIMESTAMP (¡tenga en cuenta la zona horaria!)
  • TEXT / MEDIUMTEXT / LONGTEXT → TEXT (en PostgreSQL no hay límite de tamaño para TEXT)
  • UNSIGNED INT → considere BIGINT o NUMERIC
  • ENUM('a','b') → ENUM de PostgreSQL (es necesario crear el tipo) o VARCHAR con restricción CHECK

Sincronización de datos mediante CDC con Debezium

Asegúrese de que MySQL esté configurado para la replicación binlog:

# my.cnf\n[mysqld]\nserver-id = 1\nlog_bin = /var/log/mysql/mysql-bin.log\nbinlog_format = ROW\nbinlog_row_image = FULL\nexpire_logs_days = 7

Configuración del conector MySQL de Debezium (publicado en Kafka Connect):

{\n  \"name\": \"mysql-source-connector\",\n  \"config\": {\n    \"connector.class\": \"io.debezium.connector.mysql.MySqlConnector\",\n    \"database.hostname\": \"mysql-host\",\n    \"database.port\": \"3306\",\n    \"database.user\": \"debezium\",\n    \"database.password\": \"secret\",\n    \"database.server.id\": \"184054\",\n    \"topic.prefix\": \"myapp\",\n    \"database.include.list\": \"source_db\",\n    \"schema.history.internal.kafka.bootstrap.servers\": \"kafka:9092\",\n    \"schema.history.internal.kafka.topic\": \"schema-changes.myapp\",\n    \"include.schema.changes\": \"true\",\n    \"snapshot.mode\": \"initial\"\n  }\n}

El consumidor de Kafka del lado de PostgreSQL aplica los cambios. Para ello puede usar el conector JDBC Sink ya disponible o escribir un consumidor en Go/Python que traduzca los eventos de Debezium en comandos SQL para PostgreSQL.

Arrancar todo en Docker para desarrollo y pruebas:

version: '3.8'\nservices:\n  zookeeper:\n    image: confluentinc/cp-zookeeper:7.5.0\n    environment:\n      ZOOKEEPER_CLIENT_PORT: 2181\n\n  kafka:\n    image: confluentinc/cp-kafka:7.5.0\n    depends_on: [zookeeper]\n    environment:\n      KAFKA_ZOOKEEPER_CONNECT: zookeeper:2181\n      KAFKA_ADVERTISED_LISTENERS: PLAINTEXT://kafka:9092\n\n  kafka-connect:\n    image: debezium/connect:2.5\n    depends_on: [kafka]\n    ports:\n      - \"8083:8083\"\n    environment:\n      BOOTSTRAP_SERVERS: kafka:9092\n      GROUP_ID: 1\n      CONFIG_STORAGE_TOPIC: connect_configs\n      OFFSET_STORAGE_TOPIC: connect_offsets

Adaptación de la aplicación

Incluso utilizando un ORM, habrá que realizar cambios. Los principales problemas son:

Sensibilidad a mayúsculas en los identificadores

MySQL no distingue entre mayúsculas y minúsculas en los nombres de tablas (en Windows/macOS). PostgreSQL sí es sensible y convierte todos los identificadores a minúsculas. Si el código contiene SELECT * FROM Users, funcionará en MySQL, pero no en PostgreSQL (donde se esperará la tabla users).

Modo estricto de GROUP BY

PostgreSQL requiere que todas las columnas no agregadas en SELECT estén incluidas en GROUP BY:

-- Funciona en MySQL (modo no estricto), pero no en PostgreSQL\nSELECT user_id, email, COUNT(*) FROM orders GROUP BY user_id;\n\n-- Correcto para PostgreSQL\nSELECT user_id, MAX(email), COUNT(*) FROM orders GROUP BY user_id;

INSERT ... ON CONFLICT

-- MySQL\nINSERT INTO users (id, name) VALUES (1, 'Alice')\nON DUPLICATE KEY UPDATE name = VALUES(name);\n\n-- PostgreSQL\nINSERT INTO users (id, name) VALUES (1, 'Alice')\nON CONFLICT (id) DO UPDATE SET name = EXCLUDED.name;

Particularidades del ORM

Si usa Laravel con Eloquent, tras cambiar a PostgreSQL revise todas las consultas en crudo (DB::raw()). En Django, asegúrese de que no hay anotaciones específicas de MySQL. En Go con GORM o sqlx, compruebe los placeholders: MySQL usa ?, PostgreSQL usa $1, $2, ....

Para una adaptación segura del código, añada en el CI/CD una etapa independiente con la ejecución de pruebas contra PostgreSQL antes del cambio final.

Pruebas de la migración

Comparación de datos

Tras la carga inicial y una vez establecida la sincronización CDC, es necesario verificar la consistencia de los datos:

-- Comparación del número de filas\nSELECT 'mysql' as source, COUNT(*) FROM orders\nUNION ALL\nSELECT 'postgres' as source, COUNT(*) FROM orders;\n\n-- Comparación de sumas de verificación (muestreo)\nSELECT MD5(CAST(id AS TEXT) || amount || status)\nFROM orders\nORDER BY id\nLIMIT 10000;

Utilice herramientas como pt-table-checksum (Percona Toolkit) de forma adaptada, o escriba su propio script de comparación que coteje registros por clave primaria entre ambas bases de datos.

Pruebas de carga

Antes del cut-over, realice una prueba de carga sobre PostgreSQL con un perfil de tráfico real. Use herramientas como k6, Gatling o pgbench. Verifique:

  • Tiempo de respuesta de las consultas clave (no debe ser peor que en MySQL).
  • Comportamiento bajo carga máxima.
  • Uso de memoria y conexiones (pg_stat_activity).
  • Presencia de consultas lentas (pg_stat_statements, auto_explain).

Cambio final (cut-over)

El cut-over es el momento más crítico. Minimice la ventana de riesgo preparando cuidadosamente el plan.

Plan de cut-over

  1. T-7 días: el CDC funciona, el lag de replicación es establemente inferior a 1 segundo. Todas las pruebas en verde.
  2. T-1 día: notificar al equipo. Asegurarse de que haya un DBA de guardia y un desarrollador backend disponible.
  3. T=0 (ventana de cut-over): activar el modo de solo lectura a nivel de aplicación (feature flag o modo de mantenimiento). Esperar a que el CDC aplique completamente los eventos pendientes (lag = 0). Cambiar la cadena de conexión en la configuración (o mediante service discovery) a PostgreSQL. Desactivar el modo de solo lectura. Verificar las métricas clave y el health-check.
  4. T+15 minutos: monitoreo de errores y tiempos de respuesta. Si todo está bien — éxito.

Rollback en caso de problemas

Si tras el cambio se detectan problemas críticos:

  • Redirigir inmediatamente la cadena de conexión de vuelta a MySQL.
  • MySQL habrá permanecido en funcionamiento — los datos generados durante el tiempo en PostgreSQL se perderán, pero serán solo minutos.
  • Si se usó dual-write — no habrá ninguna pérdida de datos.

Mantenga MySQL en funcionamiento al menos 2 semanas después del cut-over exitoso, por si aparecen problemas ocultos.

Post-migración: optimización y monitoreo

PostgreSQL requiere un enfoque de mantenimiento diferente al de MySQL.

VACUUM y ANALYZE

PostgreSQL utiliza MVCC, lo que provoca la acumulación de filas «muertas». Asegúrese de que autovacuum esté correctamente configurado para su perfil de carga:

-- Verificar estadísticas de autovacuum\nSELECT relname, last_autovacuum, last_autoanalyze, n_dead_tup\nFROM pg_stat_user_tables\nORDER BY n_dead_tup DESC;

Índices

Revise la estrategia de indexación. PostgreSQL soporta: B-tree, Hash, GIN (para JSONB, arrays, texto completo), GiST, BRIN (para series temporales). El uso del tipo de índice correcto proporciona una mejora significativa en el rendimiento.

Monitoreo

Active pg_stat_statements para analizar las consultas más lentas. Configure alertas sobre el bloat de tablas, la duración de las transacciones y el tamaño del WAL. Prometheus + postgres_exporter es el stack de monitoreo estándar para PostgreSQL en 2026.

Limpieza

Tras 2 semanas de funcionamiento estable en PostgreSQL:

  • Detener el pipeline de CDC.
  • Eliminar el conector de Debezium y los topics de Kafka de migración.
  • Detener el servidor MySQL.
  • Eliminar MySQL de la infraestructura o pasar a estado de archivo.

Conclusión: checklist para una migración exitosa

  • ✅ Auditoría completa del esquema realizada: tipos de datos, procedimientos almacenados, sintaxis específica.
  • ✅ Estrategia definida: CDC (Debezium) + dual-write opcional en la etapa final.
  • ✅ Binlog configurado en MySQL (formato ROW).
  • ✅ Carga inicial realizada mediante pgLoader, secuencias reiniciadas a los valores correctos.
  • ✅ Sincronización CDC iniciada, lag monitorizado.
  • ✅ Esquema de PostgreSQL adaptado: JSONB en lugar de JSON, IDENTITY en lugar de AUTO_INCREMENT, tipos de datos correctos.
  • ✅ Código de la aplicación adaptado: placeholders, GROUP BY, ON CONFLICT, mayúsculas en identificadores.
  • ✅ Etapa de pruebas contra PostgreSQL añadida al CI/CD.
  • ✅ Prueba de carga en PostgreSQL superada con resultados no inferiores a MySQL.
  • ✅ Plan de cut-over documentado y acordado con el equipo.
  • ✅ Rollback probado en entorno de staging.
  • ✅ Tras el cut-over: autovacuum, monitoreo y pg_stat_statements configurados.
  • ✅ MySQL mantenido en funcionamiento al menos 2 semanas tras el cambio exitoso.

La migración de MySQL a PostgreSQL es una inversión que da sus frutos: un sistema de tipos más rico, estricta adherencia al estándar SQL, un ecosistema de extensiones activo y un sólido respaldo de los proveedores de nube. Con una planificación adecuada, es completamente factible sin un solo minuto de tiempo de inactividad para los usuarios.

Tecnologías

Etiquetas

Ruslan Ismailov

Desarrollador Senior Web / Backend. Desarrollador senior web/backend con 9 años de experiencia. Stack: PHP, Laravel, PostgreSQL, Redis, Docker, Kubernetes, REST, microservicios, CI/CD. Más sobre mí →