Migrar de MySQL a PostgreSQL en Laravel 13: los cuatro puntos donde se rompe la app
Cambiar DB_CONNECTION a pgsql no migra nada: la app arranca y a la semana revienta por un GROUP BY, un tinyint(1) o un upsert() que en PostgreSQL exige otro índice. Estos son los cuatro puntos donde se rompe la app y el orden correcto para arreglarlos.
Primero: decide si te conviene migrar
Migrar de MySQL a PostgreSQL cuesta semanas entre inventario, ensayos y una ventana de corte. Antes de abrir pgloader, confirma que el cambio resuelve un problema que hoy puedes medir. Si no puedes nombrar ese problema en una frase con números, la respuesta es esperar.
Cinco señales reales a favor de PostgreSQL
- Reportes y consultas analíticas. Agregaciones sobre tablas grandes, funciones de ventana, CTE y
GROUPING SETSson el terreno donde el planificador de PostgreSQL se siente cómodo. - JSON que se consulta de verdad.
jsonbguarda el documento en formato binario y admite índices GIN, así que filtrar por contenido deja de ser un escaneo completo. - Concurrencia de escritura. Varias colas escribiendo en la misma tabla conviven mejor: las lecturas no esperan a las escrituras.
- DDL transaccional. Si una migración falla a mitad, el esquema vuelve atrás completo en lugar de quedar a medias.
- Extensiones. pgvector para búsqueda semántica, PostGIS para datos geográficos, pg_trgm para búsquedas difusas. Instalar una es un
CREATE EXTENSION, no un proyecto.
Tres casos en los que quedarse en MySQL es la respuesta correcta
- El equipo solo conoce MySQL. Operar un motor que nadie domina cuesta más que cualquier consulta lenta que estés evitando.
- El hosting no ofrece PostgreSQL. Si el proveedor solo da MySQL gestionado, migrar significa administrar el motor tú mismo, con respaldos y actualizaciones incluidos.
- La app no tiene dolor. Una tienda con 20.000 filas y consultas de menos de 50 ms no gana nada con el cambio.
La regla del dolor medible
Migra cuando puedas señalar el número: consultas que pasan de un segundo en el log lento, migraciones que bloquean una tabla durante veinte minutos, reportes que se arman en PHP recorriendo 50.000 filas porque el SQL no alcanza. Ese número es tu criterio de éxito y la forma de saber, seis meses después, si la migración valió la pena.
Las diferencias que rompen código
Estas son las que aparecen en producción, no en el primer arranque. La tabla las resume; debajo está el síntoma concreto de cada una.
Lee también
| MySQL | PostgreSQL | Síntoma que vas a ver |
|---|---|---|
AUTO_INCREMENT | GENERATED ... AS IDENTITY | Los INSERT nuevos chocan con duplicate key porque la secuencia quedó en 1 |
tinyint(1) | boolean | Una comparación === 1 que antes funcionaba ahora falla con true |
json | jsonb | whereJsonContains devuelve vacío o lanza error de operador |
ON DUPLICATE KEY UPDATE | ON CONFLICT (...) DO UPDATE | Filas duplicadas o error de constraint inexistente |
LIKE | LIKE / ILIKE | Buscador que deja de encontrar resultados en minúsculas |
GROUP BY permisivo | GROUP BY estricto | Error de columna no agrupada en una consulta que llevaba años corriendo |
Identificadores, mayúsculas y comillas
PostgreSQL convierte a minúsculas cualquier identificador que no vaya entre comillas dobles: una tabla UserLogs creada sin comillas se llama userlogs. MySQL en Linux, con el sistema de archivos sensible a mayúsculas, trata UserLogs y userlogs como tablas distintas. La regla que evita el problema completo es usar snake_case en tablas y columnas, y comillas dobles solo cuando el nombre lo exija. Olvida también el backtick: en PostgreSQL los identificadores van con comillas dobles y las cadenas con comillas simples.
AUTO_INCREMENT contra IDENTITY
Las columnas autoincrementales se declaran con GENERATED BY DEFAULT AS IDENTITY. serial y bigserial siguen existiendo por compatibilidad, pero IDENTITY es la forma estándar y la que genera Laravel con $table->id(). La trampa no está en el esquema sino en los datos: al copiar filas con su id explícito, la secuencia asociada no avanza. El primer INSERT después del corte intenta usar el id 1 y falla con duplicate key value violates unique constraint.
tinyint(1) y los booleanos que ahora son true y false
En MySQL un booleano es un tinyint(1) que el cliente devuelve como 0 o 1. En PostgreSQL es un tipo boolean real que el driver entrega como true o false. Todo código que compare con === 1, == 1 o que use el valor como índice de array se rompe en silencio. Busca las comparaciones y cámbialas por true/false o por un cast explícito en el borde de la aplicación.
JSON contra JSONB: el que indexa es uno solo
En PostgreSQL, json guarda el texto tal como llegó y no tiene operador de contención. jsonb lo guarda en formato binario, permite el operador @> y admite índices GIN. Si una columna se queda como json, whereJsonContains no tiene sobre qué trabajar. Migra el tipo a jsonb en el esquema y en los datos, no solo en la migración de Laravel.
ON DUPLICATE KEY contra ON CONFLICT
MySQL ejecuta el UPDATE cuando choca con cualquier clave única de la tabla. PostgreSQL exige declarar la columna o el constraint del conflicto: ON CONFLICT (external_id) DO UPDATE SET .... Es más preciso y por eso más frágil si el índice no existe. Si no hay índice único en esa combinación, el motor responde con there is no unique or exclusion constraint matching the ON CONFLICT specification.
LIKE, ILIKE y el GROUP BY estricto
LIKE distingue mayúsculas en PostgreSQL. Para un buscador de usuarios necesitas ILIKE o comparar con lower(); en Laravel, whereLike('name', $term, caseSensitive: false) elige el operador correcto en cada motor. El GROUP BY es estricto por defecto, igual que activar ONLY_FULL_GROUP_BY en MySQL: toda columna del SELECT que no esté agregada debe aparecer en el GROUP BY. Tampoco hay UPDATE ... LIMIT ni UNSIGNED.
Qué cambia dentro de Laravel 13
La conexión pgsql y las migraciones
El driver ya viene en config/database.php; lo que cambia es el bloque del .env y el reinicio de los workers de cola, que mantienen la conexión vieja en memoria.
DB_CONNECTION=pgsql
DB_HOST=127.0.0.1
DB_PORT=5432
DB_DATABASE=app_db
DB_USERNAME=app_user
DB_PASSWORD=secretoEn las migraciones gana $table->jsonb('meta') sobre $table->json(), y $table->boolean() se traduce a un booleano nativo. $table->enum() no crea un tipo enumerado: genera un varchar con un CHECK, lo que evita el dolor de alterar tipos después.
upsert(): el índice único que en MySQL no hacía falta
Laravel exige clave primaria o índice único en las columnas del conflicto. Si el índice no existe, el upsert() no se degrada: falla o inserta duplicados según el caso.
DB::table('orders')->upsert(
[['external_id' => 'A-1', 'status' => 'paid', 'updated_at' => now()]],
['external_id'],
['status', 'updated_at']
);Revisa cada upsert() del proyecto y comprueba que exista el índice único correspondiente. Si el código sincronizaba por email en MySQL porque cualquier clave única disparaba el UPDATE, en PostgreSQL necesitas el índice único en email o cambiar a updateOrCreate(), más lento pero explícito.
whereJsonContains sobre jsonb y los índices GIN
El método se apoya en @>, que solo existe en jsonb. Con el tipo corregido, agrega el índice:
CREATE INDEX orders_meta_gin ON orders USING gin (meta);Para una clave concreta conviene un índice por expresión, mucho más pequeño: CREATE INDEX orders_meta_status ON orders ((meta->>'status'));.
CREATE INDEX CONCURRENTLY fuera de la transacción
Un índice concurrente no puede correr dentro de una transacción, y las migraciones de Laravel envuelven todo por defecto. La migración necesita salir de ese bloque:
public $withinTransaction = false;
public function up(): void
{
DB::statement('CREATE INDEX CONCURRENTLY IF NOT EXISTS orders_meta_gin ON orders USING gin (meta)');
}Si tu versión no respeta esa propiedad, el equivalente es ejecutar el DB::statement desde un comando artisan en el despliegue, fuera de DB::transaction.
Copiar los datos con pgloader
El comando mínimo y qué hace por dentro
pgloader lee el catálogo de MySQL, crea las tablas y los índices en PostgreSQL, convierte tipos y copia los datos con COPY. El comando mínimo sirve para una prueba rápida contra una base vacía:
pgloader mysql://app_user:secreto@127.0.0.1/app_db \
postgresql://app_user:secreto@127.0.0.1/app_dbUn archivo de carga con CAST y reset sequences
Para una migración real conviene un archivo de carga, donde cada decisión queda escrita y versionada:
LOAD DATABASE
FROM mysql://app_user:secreto@127.0.0.1/app_db
INTO postgresql://app_user:secreto@127.0.0.1/app_db
WITH include drop, create tables, create indexes, reset sequences,
foreign keys, workers = 8, concurrency = 1,
prefetch rows = 10000
SET MySQL PARAMETERS
net_read_timeout = '600',
net_write_timeout = '600'
CAST type datetime to timestamptz drop default drop not null
using zero-dates-to-null,
type date drop not null drop default
using zero-dates-to-null,
type tinyint when unsigned to smallint drop typemod,
type json to jsonb;Las tres piezas que importan: zero-dates-to-null convierte los 0000-00-00 de MySQL en NULL, porque PostgreSQL no acepta esa fecha; tinyint sin signo no tiene equivalente directo y pasa a smallint; y json to jsonb es lo que habilita los índices GIN. reset sequences ajusta las secuencias al máximo existente y evita el fallo del primer INSERT.
Verificar la copia: conteos, sumas de control y secuencias
Compara el conteo por tabla en los dos motores con count(*): las estimaciones de pg_stat_user_tables no sirven para validar. Suma o calcula un hash sobre una columna clave en ambas bases y contrasta el resultado. Después revisa las secuencias a mano:
SELECT setval(pg_get_serial_sequence('orders','id'),
COALESCE((SELECT MAX(id) FROM orders), 1));El COALESCE cubre la tabla vacía: sin él la consulta devuelve NULL y la secuencia queda inservible. Repite en todas las tablas con id autoincremental, incluidas las de pivote.
El plan de corte paso a paso
Inventario de SQL crudo antes de tocar nada
El SQL escrito a mano es donde aparecen las sorpresas, porque no pasa por el generador de consultas de Eloquent. Busca DB::raw, selectRaw, whereRaw, orderByRaw y havingRaw en app/ y database/, y revisa además vistas de base de datos y jobs que arman SQL por concatenación. Las consultas lentas que ya tienes identificadas en el log de MySQL son el mejor punto de partida: el diagnóstico de consultas lentas en MySQL 8.4 explica cómo sacarlas del log.
Ensayo general contra un Postgres de verdad
Levanta un contenedor con la misma versión que usarás en producción y corre la suite completa contra él:
docker run --rm -p 5432:5432 -e POSTGRES_PASSWORD=secreto postgres:18SQLite no valida esta migración: no tiene jsonb, no tiene ILIKE, no aplica los tipos estrictos y da falsos positivos que aparecen en producción. Un ensayo serio usa una copia real de los datos, no un seed de diez filas. Si el despliegue corre sobre un VPS, el repaso de cómo desplegar Laravel 13 en un VPS con cero downtime cubre el .env, los workers y el reinicio de la cola.
Ventana de corte, doble escritura y rollback
Con ventana, el orden es: modo lectura en la app, última copia incremental, verificación de conteos, cambio de conexión, php artisan queue:restart, limpieza de cachés de esquema y apertura. Sin ventana, la doble escritura durante unos días resuelve el corte, a cambio de duplicar la lógica de escritura y vigilar las diferencias entre motores.
El rollback se prepara antes, no durante: MySQL queda congelado y disponible durante 72 horas, con la conexión elegida por variable de entorno. Volver es cambiar una línea y reiniciar workers, no restaurar un respaldo.
Rendimiento en el primer mes
EXPLAIN (ANALYZE, BUFFERS) en lugar de SHOW PROFILE
PostgreSQL no tiene SHOW PROFILE. La herramienta es EXPLAIN (ANALYZE, BUFFERS), que muestra el tiempo real de cada nodo y cuántas páginas se leyeron de caché y de disco. Un plan con Seq Scan sobre una tabla de un millón de filas y cien buffers suele significar índice faltante, no falta de memoria.
Autovacuum, conexiones y PgBouncer
Autovacuum viene encendido y no se apaga: es lo que mantiene el espacio reutilizable y las estadísticas al día. Revisa pg_stat_user_tables para detectar tablas con muchas filas muertas. Si hay muchos workers de cola, el primer cuello de botella es el límite de conexiones, y la solución es PgBouncer en modo transaction antes que subir max_connections.
Checklist y siguientes pasos
- Dolor medido y anotado antes de empezar.
- Inventario de SQL crudo terminado, con vistas y jobs incluidos.
- Copia real migrada con pgloader y archivo de carga versionado.
- Conteos y sumas de control iguales en los dos motores.
- Secuencias reseteadas en todas las tablas con id.
- Suite de pruebas verde contra PostgreSQL 18, no contra SQLite.
- Todos los
upsert()con índice único verificado. - Columnas JSON migradas a
jsonbcon índices GIN donde hacen falta. - Rollback probado de verdad, con workers reiniciados.
- Plan de seguimiento: consultas lentas y
EXPLAINdurante el primer mes.
Migrar de MySQL a PostgreSQL es un cambio de semántica, no un cambio de conector: los datos, los tipos y las consultas tienen reglas nuevas. Si el paso siguiente es elegir versión, hoy la decisión es PostgreSQL 18 en producción, porque la 19 se retrasó al 19 de noviembre y todavía no es estable. En el blog hay más guías de Laravel en producción para seguir con el despliegue y el rendimiento.

