Abre un archivo guardado en UTF-8 con una herramienta que asume otra codificación y café se convierte en café. Los bytes del disco no cambiaron. Lo que cambió es la regla usada para volver a convertirlos en caracteres, y las dos reglas no coinciden en qué significa el byte 0xC3.
La mayoría de los bugs de “caracteres raros” vienen de la misma fuente: nombres de archivo rotos, formularios ilegibles, un emoji que hace fallar un insert en la base de datos, dos cadenas que se ven idénticas pero fallan al compararlas. Detrás de todos ellos hay la misma confusión entre dos cosas distintas: Unicode y UTF-8.
Unicode vs UTF-8: los code points no son bytes
Unicode es una única tabla que asigna a cada carácter un número, llamado code point, escrito como U+ seguido de hexadecimal: A es U+0041, é es U+00E9, 😀 es U+1F600. La tabla va de U+0000 a U+10FFFF. Cubre todos los sistemas de escritura en uso, actuales e históricos, además de símbolos, emojis y rangos reservados para uso privado.
Unicode no dice nada sobre bytes. Responde a una sola pregunta: ¿qué número tiene este carácter? Convertir ese número en bytes que puedas guardar en un archivo o enviar por un socket es un trabajo aparte, que hace una codificación. UTF-8, UTF-16 y UTF-32 son tres codificaciones de la misma tabla. Escriben los mismos code points como secuencias de bytes distintas.
Es el mismo tipo de brecha que hace que 0.1 + 0.2 devuelva algo distinto de 0.3: el valor abstracto y los bits en memoria son dos capas distintas, y el bug aparece en cuanto las tratas como intercambiables.
Cómo UTF-8 convierte un code point en bytes
UTF-8 es de ancho variable. Cada code point se convierte en 1 a 4 bytes, usando solo los que necesita.
| Rango de code point | Bytes usados | Patrón de bytes |
|---|---|---|
U+0000–U+007F | 1 | 0xxxxxxx |
U+0080–U+07FF | 2 | 110xxxxx 10xxxxxx |
U+0800–U+FFFF | 3 | 1110xxxx 10xxxxxx 10xxxxxx |
U+10000–U+10FFFF | 4 | 11110xxx 10xxxxxx 10xxxxxx 10xxxxxx |
Todo byte que empieza con 10 es un byte de continuación: parte de un carácter multibyte, nunca un carácter por sí solo. Esto hace que la codificación sea autosincronizable. Entra en medio de un flujo UTF-8, retrocede más allá de cualquier byte de continuación, y llegas al inicio de un carácter.
La primera fila explica por qué ganó UTF-8. Los code points U+0000–U+007F son exactamente ASCII, y se guardan con el mismo byte que ASCII ya usaba, así que cualquier archivo ASCII puro ya es UTF-8 válido, byte a byte. Todo lo que queda fuera de ese rango cuesta bytes extra. é es U+00E9, en la segunda fila, así que ocupa dos:
| |
😀 es U+1F600, en la cuarta fila, así que ocupa cuatro:
| |
Ese par 0xC3 0xA9 es el culpable del ejemplo inicial. Decodifícalo como UTF-8 y obtienes é. Decodifícalo como Latin-1, que mapea cada byte a un carácter, y 0xC3 se convierte en à mientras que 0xA9 se convierte en ©. Nada en un flujo de bytes crudo registra qué codificación lo produjo. Los bytes no están mal. Lo que está mal es la suposición de quien los lee.
Por qué .length no cuenta caracteres
“Cuánto mide esta cadena” tiene tres respuestas, porque “carácter” puede significar tres unidades distintas.
len() en Python cuenta code points:
| |
Un code point en el str, cuatro bytes en el bytes codificado. Dos preguntas distintas, dos respuestas distintas.
Las cadenas de JavaScript son UTF-16 por dentro, y .length cuenta unidades de código de 16 bits, no code points. Los code points por encima de U+FFFF, que incluyen todos los emojis, necesitan dos unidades UTF-16, llamadas par sustituto:
| |
El emoji de familia "👨👩👧👦" lo complica más. Son 7 code points: 4 emojis de persona unidos por 3 zero-width joiners. Cada uno de los 4 emojis es un par sustituto, así que .length reporta 11. Lo que un lector ve como un carácter es un grapheme cluster, y puede abarcar cualquier cantidad de code points. Cortar o invertir una cadena por unidad de código o por code point puede partir ese emoji por la mitad. Intl.Segmenter cuenta como lo haría una persona:
| |
El ancho variable también tiene un costo. En un buffer UTF-8, encontrar el code point número 50 significa recorrer desde el inicio y contar, así que indexar por posición de carácter es O(n), no la búsqueda en tiempo constante que obtienes de un array de ancho fijo. Los lenguajes manejan esto de forma distinta. Rust y Go guardan las cadenas como UTF-8 y las indexan por byte, así que iteras cuando quieres caracteres. CPython guarda cada str con un ancho fijo por cadena (1, 2 o 4 bytes por code point, según el carácter más ancho que contenga), lo que mantiene s[i] en tiempo constante al precio de memoria extra en cuanto aparece un solo emoji.
Bugs de codificación UTF-8 habituales en producción
Truncar por cantidad de bytes. Cortar una cadena UTF-8 exactamente en 100 bytes a veces cae en medio de un carácter multibyte, dejando una secuencia parcial que no se puede decodificar. Trunca por cantidad de code points, o trunca los bytes y decodifica con errors="ignore", aceptando que el último carácter parcial se descarte. Ninguno de los dos enfoques respeta los grapheme clusters: un corte por code point todavía puede separar una e de su acento combinante o partir un emoji de familia. Para texto visible al usuario, corta en los límites de los grapheme clusters.
Dejar la codificación implícita en la E/S de archivos. open("data.txt") sin el argumento encoding= usa la codificación local del sistema operativo. En Linux y macOS suele ser UTF-8. En Windows históricamente ha sido una code page como cp1252. Leer un archivo UTF-8 con el valor por defecto equivocado produce un UnicodeDecodeError o mojibake silencioso, según los bytes. La PEP 686 hace que UTF-8 sea el valor por defecto en todas partes desde Python 3.15, pero mucho código sigue corriendo en versiones anteriores o definiendo PYTHONUTF8=0. Pasa la codificación de forma explícita y el problema deja de aparecer:
| |
Una columna de MySQL que es UTF-8 solo de nombre. El character set utf8 de MySQL (hoy un alias de utf8mb3) guarda como máximo 3 bytes por carácter. No puede contener code points por encima de U+FFFF, así que nada de emojis y nada fuera del Basic Multilingual Plane. Inserta 😀 en una columna utf8 y obtienes Incorrect string value. La solución es utf8mb4, la implementación UTF-8 completa de MySQL y el valor por defecto desde MySQL 8.0. Convertir una tabla es ALTER TABLE t CONVERT TO CHARACTER SET utf8mb4, que se ejecuta tabla por tabla, y el charset de la conexión también tiene que ser utf8mb4 o el cliente estropea los emojis antes de que lleguen a la columna. En los row formats antiguos de InnoDB, vigila el límite de 767 bytes del prefijo de índice: un VARCHAR(255) indexado cabe en utf8mb3 (765 bytes) pero no en utf8mb4 (1.020 bytes).
Dos secuencias de bytes para el mismo carácter visible. é puede ser el code point precompuesto único U+00E9 (forma NFC), o la letra e (U+0065) seguida de un acento agudo combinante U+0301 (forma NFD). Se ven idénticas pero difieren byte a byte, así que dos cadenas café pueden comparar como distintas según donde se escribieran o pegaran. Los nombres de archivo en macOS y el texto copiado de algunos PDFs suelen llegar en NFD. Un diccionario indexado con una forma puede no encontrar una entrada guardada con la otra, sin avisar. Normaliza antes de comparar o guardar:
| |
Una marca de orden de bytes de más. Un archivo UTF-8 puede empezar con los 3 bytes EF BB BF, un BOM que escriben algunos editores (entre ellos el viejo Notepad de Windows) para señalar la codificación. UTF-8 no tiene un orden de bytes que señalar, así que ese BOM no aporta información. Es invisible en la mayoría de editores, pero decodificarlo como utf-8 simple deja un carácter al principio de la cadena, que rompe los parsers JSON estrictos e impide que se reconozca un shebang #!. Decodifica con el codec utf-8-sig de Python cuando esperes uno, y no escribas uno tú mismo.
Cómo frenar los bugs de codificación en la frontera
Elige UTF-8 y decláralo explícitamente en cada punto donde el texto entra o sale de tu programa: lecturas de archivo, conexiones a base de datos, cuerpos HTTP, pipes hacia subprocesos. Decodifica a cadena en cuanto llegan los bytes, trabaja internamente con cadenas decodificadas, y codifica de vuelta a bytes solo en la siguiente frontera. Mantén errors="strict" (el valor por defecto de Python) para que una secuencia de bytes mala lance una excepción en lugar de convertirse silenciosamente en U+FFFD o desaparecer.
Después, prueba con entradas que no sean ASCII. Los fixtures que solo contienen U+0000–U+007F dejan pasar los bugs de codificación a producción, porque cada ruta de ejecución funciona por casualidad a un byte por carácter hasta que el nombre de un usuario real rompe esa suposición. Añade dos casos a cualquier nivel de prueba que ya escribas para cada campo de texto, cuadro de búsqueda, manejador de nombres de archivo o exportación: una cadena con una letra acentuada, escrita en NFC y en NFD, y una con el emoji de familia. Si sobreviven un round trip por tu almacenamiento y vuelven iguales, las fronteras están haciendo su trabajo.