Migrar MySQL de utf8 a utf8mb4 en PHP sin perder datos ni tiempo de actividad

El charset utf8 de MySQL existe desde la versión 3.23 y tiene un defecto de diseño que lleva décadas causando problemas: solo soporta hasta 3 bytes por carácter. UTF-8 real puede necesitar hasta 4 bytes para representar ciertos caracteres, y eso deja fuera a todos los emojis y a bastantes símbolos matemáticos, monedas y escrituras asiáticas poco comunes.

El resultado práctico es que intentas guardar un ???? en una columna utf8 y MySQL te lo convierte en ???? sin avisar, o directamente lanza un error si tienes el modo estricto activado. No es un bug que vayas a ver en los logs hasta que alguien se queja de que su comentario llegó mutilado.

MySQL creó utf8mb4 en la versión 5.5.3 para solucionar esto. El nombre significa "UTF-8 multibyte de 4 bytes" y soporta el rango completo de Unicode, incluyendo todos los emojis actuales y futuros. Si tu aplicación maneja texto generado por usuarios, deberías estar en utf8mb4.

Detectar si tienes el problema

Antes de tocar nada, comprueba el estado actual de tu servidor y tus tablas.

Ver el charset del servidor

SHOW VARIABLES LIKE 'character_set%';
SHOW VARIABLES LIKE 'collation%';

Lo que te interesa es character_set_server y character_set_database. Si ves utf8, tienes trabajo por delante. Si ves utf8mb4, el servidor ya está bien configurado, pero puede que las tablas antiguas todavía no lo estén.

Ver el charset de una tabla concreta

SHOW CREATE TABLE mi_tablaG

Al final del resultado verás algo como DEFAULT CHARSET=utf8 o DEFAULT CHARSET=utf8mb4. Comprueba también las columnas de texto: una tabla puede tener charset utf8mb4 pero columnas individuales que se crearon con utf8 y no se tocaron.

La prueba rápida del emoji

INSERT INTO mi_tabla (columna_texto) VALUES ('????');
SELECT columna_texto FROM mi_tabla ORDER BY id DESC LIMIT 1;

Si el resultado es ???? o da error, el charset es utf8. Si aparece el emoji tal cual, ya estás en utf8mb4.

Plan de migración paso a paso

Paso 0: el backup

Sin backup no hay paso siguiente. Haz un mysqldump completo antes de empezar. Con bases de datos grandes puedes usar --single-transaction para no bloquear lecturas en InnoDB:

mysqldump --single-transaction --routines --triggers -u usuario -p nombre_bd > backup_antes_utf8mb4.sql

Paso 1: cambiar my.cnf

Añade o modifica estas líneas en /etc/mysql/my.cnf (o my.ini en Windows):

[mysqld]
character-set-server = utf8mb4
collation-server = utf8mb4_unicode_ci

[mysql]
default-character-set = utf8mb4

[client]
default-character-set = utf8mb4

Esto afecta a las conexiones nuevas y a las tablas que se creen a partir de ahora. Las tablas existentes no cambian solas.

Paso 2: reiniciar MySQL

Necesitas reiniciar el servicio para que apliquen los cambios de my.cnf. Si tienes réplicas de lectura, migra una réplica primero y verifica que funciona antes de tocar la primaria:

systemctl restart mysql

Paso 3: convertir la base de datos

ALTER DATABASE mi_bd CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;

Esto cambia el charset por defecto para las tablas nuevas. Las tablas existentes siguen sin tocar.

Paso 4: convertir cada tabla

ALTER TABLE mi_tabla CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;

Repite esto para cada tabla. Si tienes muchas, puedes generar las sentencias automáticamente:

SELECT CONCAT('ALTER TABLE ', TABLE_NAME, ' CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;')
FROM information_schema.TABLES
WHERE TABLE_SCHEMA = 'mi_bd'
AND TABLE_TYPE = 'BASE TABLE';

Copia el resultado y ejecútalo. Con tablas grandes, este ALTER TABLE bloquea escrituras mientras se procesa. Si no puedes permitirte eso, ve al apartado de migración sin tiempo de actividad.

Paso 5: verificar los índices

Después del CONVERT TO, comprueba que no hay errores de longitud de índice. En MySQL 5.6 con InnoDB hay un límite de 767 bytes por columna indexada, y con utf8mb4 ese límite se alcanza antes.

El problema de los índices

Con utf8 cada carácter ocupa como máximo 3 bytes, así que una columna VARCHAR(255) puede llegar a 765 bytes en un índice, justo por debajo del límite de 767 bytes de InnoDB. Con utf8mb4 cada carácter puede ocupar 4 bytes, y esa misma columna llegaría a 1020 bytes, superando el límite.

Hay dos formas de resolverlo:

  • Activar innodb_large_prefix: disponible desde MySQL 5.6 y activado por defecto en MySQL 5.7+. Requiere también innodb_file_format=Barracuda y ROW_FORMAT=DYNAMIC en las tablas. Con esto el límite sube a 3072 bytes y el problema desaparece.
  • Reducir la longitud de las columnas indexadas: con utf8mb4, el máximo seguro sin innodb_large_prefix es VARCHAR(191) (191 × 4 = 764 bytes, por debajo del límite). Si tienes columnas VARCHAR(255) con índice único o clave primaria en MySQL 5.6, toca reducirlas.

En MySQL 5.7 y 8.0 esto ya no es un problema porque innodb_large_prefix viene activado por defecto. Si estás en 5.6, comprueba la versión exacta y actívalo en my.cnf si no está.

Ajustar la conexión PHP

Aunque hayas migrado todas las tablas a utf8mb4, si PHP negocia la conexión en utf8, los datos de 4 bytes seguirán fallando o corrompiendo. El charset de la conexión determina cómo MySQL interpreta lo que PHP envía.

Con PDO

$dsn = 'mysql:host=localhost;dbname=mi_bd;charset=utf8mb4';
$pdo = new PDO($dsn, $usuario, $password);
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);

El charset=utf8mb4 en el DSN es todo lo que necesitas. PDO lanza el SET NAMES utf8mb4 automáticamente al conectar.

Con mysqli

$mysqli = new mysqli($host, $usuario, $password, $bd);
$mysqli->set_charset('utf8mb4');

Usa set_charset, no query("SET NAMES utf8mb4"). La diferencia es que set_charset también actualiza el estado interno de la extensión mysqli, lo que afecta al escapado de cadenas.

Con Laravel

En config/database.php, dentro de la conexión MySQL:

'charset'   => 'utf8mb4',
'collation' => 'utf8mb4_unicode_ci',

Laravel lleva esto configurado por defecto desde la versión 5.4 para nuevos proyectos. Si tienes un proyecto antiguo, comprueba que el valor no siga siendo utf8. Para más detalles sobre migraciones de base de datos en Laravel y cómo gestionar el esquema de forma ordenada, tienes una guía completa en programacion.net.

Migración sin tiempo de actividad

Para tablas grandes, el ALTER TABLE ... CONVERT TO CHARACTER SET puede tardar minutos o incluso horas y bloquea escrituras durante todo ese tiempo. Si no puedes permitirte esa ventana, tienes dos opciones.

pt-online-schema-change (Percona Toolkit)

Esta herramienta crea una tabla nueva con el charset correcto, copia los datos en segundo plano usando triggers para capturar los cambios que van llegando, y al terminar hace un RENAME atómico. Las escrituras no se bloquean en ningún momento:

pt-online-schema-change 
  --alter "CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci" 
  --execute 
  D=mi_bd,t=mi_tabla

Con tablas de varios millones de filas, esto puede tardar igualmente mucho tiempo, pero sin bloquear la aplicación.

Estrategia con réplicas

Si usas réplicas de lectura, el flujo habitual es: migrar una réplica primero, hacer que la aplicación apunte a esa réplica para lecturas, verificar que todo funciona, promover la réplica a primaria y migrar la antigua primaria como réplica. Así el tiempo de actividad es mínimo, lo que afecta es solo el failover.

Verificación post-migración

Tras la migración, haz estas comprobaciones antes de dar el proceso por terminado.

Desde la consola de MySQL, verifica que un emoji se guarda con 4 bytes:

SELECT HEX('????');

El resultado debe ser F09F9880. Si ves 3F3F3F3F (cuatro signos de interrogación en hex), la conexión sigue en utf8.

Desde PHP, haz una inserción real y lee de vuelta:

$mysqli->set_charset('utf8mb4');
$stmt = $mysqli->prepare("INSERT INTO prueba (texto) VALUES (?)");
$emoji = 'Funciona: ???? ????';
$stmt->bind_param('s', $emoji);
$stmt->execute();

$resultado = $mysqli->query("SELECT texto FROM prueba ORDER BY id DESC LIMIT 1");
$fila = $resultado->fetch_assoc();
echo $fila['texto'];

Si el emoji aparece correctamente, la migración está bien. Revisa también los logs de errores de MySQL durante las primeras 24 horas después de la migración: si hay tablas o columnas que se te pasaron por alto, aparecerán ahí.

La migración a utf8mb4 es uno de esos cambios que parece grande pero que con un backup decente y el proceso ordenado no da problemas. Si tienes dudas sobre cómo aplicar estas mismas buenas prácticas al resto de tu código, en programacion.net encontrarás más sobre PHP moderno: buenas prácticas en 2025-2026.

Imagen: Pexels / Digital Buggu

COMPARTE ESTE ARTÍCULO

COMPARTIR EN FACEBOOK
COMPARTIR EN TWITTER
COMPARTIR EN LINKEDIN
COMPARTIR EN WHATSAPP