Mojibake: por qué ves ñ y é y cómo arreglarlo
Reproduce ñ y ¿ a propósito, lee el patrón, repara texto doblemente codificado con encode/decode y mide qué mojibake tiene vuelta atrás.
Serie: curso de texto y codificación 2/6 (principiante). Para quien ya vio año, canción, ¿ o � en su app, en una base de datos o en un CSV, y quiere saber qué pasó y si tiene arreglo. Al terminar vas a poder reproducir el mojibake a propósito, reconocer los patrones de un vistazo, reparar texto doblemente codificado sin riesgo y saber qué casos están perdidos para siempre.
Este curso es para quien recibe el ticket de "otra vez salen caracteres raros": una importación donde cada ñ se volvió ñ, un sistema contable viejo que exporta en Windows-1252, un log que dice UnicodeDecodeError: 'utf-8' codec can't decode byte 0xf1. Mojibake (palabra japonesa para "caracteres transformados") es lo que obtienes cuando unos bytes correctos se decodifican con la codificación equivocada. La buena noticia: en español casi siempre tiene vuelta atrás, siempre que nadie lo haya "arreglado" antes con errors="replace". Todo lo de abajo se ejecutó en una Mac con Apple Silicon (M1 Pro) con Python 3.9.6, PHP 8.5.6 y el iconv de macOS; las rutas de los tracebacks se acortaron a ~/demo/.
Si bytes, code points y UTF-8 son nuevos para ti, lee primero la lección 1. Esta supone que puedes leer c3 b1 como dos bytes.
Cómo se produce: bytes correctos, decodificador equivocado
Solo hay dos direcciones, y cada una tiene su aspecto.
Bytes UTF-8 decodificados como una codificación antigua (Windows-1252, Latin-1). Cada letra no ASCII se convierte en dos o tres caracteres "extranjeros" y el texto crece. Esta decodificación casi nunca falla, por eso el daño se propaga sin que nadie lo note.
Bytes antiguos decodificados como UTF-8. Los bytes de Windows-1252 casi nunca son UTF-8 válido, así que un decodificador estricto se detiene con UnicodeDecodeError y uno tolerante muestra � (U+FFFD, REPLACEMENT CHARACTER).
Puedes producir los dos en dos líneas:
print("año ¿Qué?".encode("utf-8").decode("cp1252")) # año ¿Qué?
print("año ¿Qué?".encode("cp1252").decode("utf-8", "replace")) # a�o �Qu�?
¿Por qué justo ñ? La ñ es c3 b1 en UTF-8. En Windows-1252 el byte c3 es à y b1 es ±. Todas las vocales acentuadas y la ñ empiezan con c3, por eso la à aparece por todos lados; ¿ y ¡ empiezan con c2, que se ve como Â.
El mismo mecanismo afecta a todos los idiomas, cada uno con su codificación antigua. Ejecuté las combinaciones que más se ven:
| Original | Guardado como | Leído como | Ves |
|---|---|---|---|
año |
UTF-8 | cp1252 | año |
canción |
UTF-8 | latin-1 | canción |
¿Qué tal? ¡Olé! |
UTF-8 | cp1252 | ¿Qué tal? ¡Olé! |
ÁFRICA |
UTF-8 | cp1252 | UnicodeDecodeError, o �FRICA con replace |
año |
cp1252 | UTF-8 | a�o, o UnicodeDecodeError |
don’t |
UTF-8 | cp1252 | don’t |
한글 (coreano) |
cp949 | cp1252 | ÇÑ±Û |
文字化け (japonés) |
UTF-8 | cp932 | 譁�蟄怜喧縺� |
Latin-1 (ISO-8859-1) y Windows-1252 coinciden en todos los bytes excepto 0x80–0x9F, que es justo donde Windows-1252 pone €, ’, “, ”, — y ™. Por ejemplo, "año €" en Latin-1 con errors="replace" dio b'a\xf1o ?' (Latin-1 no tiene el euro), mientras que en cp1252 dio b'a\xf1o \x80'. Esa diferencia importa a la hora de reparar.
Tus primeros cinco minutos: el mensaje de error real
Guarda un archivo como lo haría un programa viejo de Windows y ábrelo de la forma por defecto:
# make_old.py: los bytes que escribiría un programa viejo de Windows
open("old.txt", "wb").write("año\n".encode("cp1252"))
# readfile.py: ejecútalo como python3 readfile.py old.txt
import sys
with open(sys.argv[1]) as f: # sin encoding= → UTF-8 en macOS y Linux
print(f.read())
Traceback (most recent call last):
File "~/demo/readfile.py", line 3, in <module>
print(f.read())
File ".../lib/python3.9/codecs.py", line 322, in decode
(result, consumed) = self._buffer_decode(data, self.errors, final)
UnicodeDecodeError: 'utf-8' codec can't decode byte 0xf1 in position 1: invalid continuation byte
Lee la última línea como un detective. 0xf1 en la posición 1 (contando desde 0, justo después de la a) es la ñ en Windows-1252 y Latin-1. En UTF-8, f1 anuncia un carácter de 4 bytes, pero el siguiente byte es 6f (o), que no es de continuación: de ahí invalid continuation byte. Una sola letra acentuada por byte, con valores entre e0 y ff, es la firma de Latin-1 o Windows-1252. La solución es decir qué es el archivo: open("old.txt", encoding="cp1252") imprimió año.
xxd lo confirma en una línea: 61 f1 6f 0a. Un byte por carácter y f1 para la ñ es una codificación de un byte, no UTF-8 (c3 b1). Con iconv -f WINDOWS-1252 -t UTF-8 old.txt sale año.
Cómo reconocer los patrones
Con un poco de práctica, la basura te dice su causa. Estas son salidas de las ejecuciones de arriba:
| Ves | Causa típica | Reparación |
|---|---|---|
ñ, é, ó, ¿, ¡ |
UTF-8 leído como cp1252 | s.encode("cp1252").decode("utf-8") |
ñ (cuatro caracteres por uno) |
lo anterior, hecho dos veces | aplica la reparación dos veces |
Ã� antes de una mayúscula |
Á, Í (bytes 81, 8D) con replace |
perdido; vuelve a los bytes originales |
a�o, canci�n (ASCII intacto, una � por letra acentuada) |
cp1252/Latin-1 leído como UTF-8 con reemplazo | vuelve a leer los bytes originales como cp1252 |
’, “ |
comillas tipográficas en UTF-8 leídas como cp1252 | s.encode("cp1252").decode("utf-8") |
Reparar la doble codificación
El caso más común en la vida real es "UTF-8 leído como cp1252 y guardado otra vez como UTF-8": el texto ahora es UTF-8 válido, solo que contiene ñ. Como no se perdió ningún byte, puedes recorrer los pasos al revés:
print("año".encode("cp1252").decode("utf-8")) # año
print("¿Qué tal? ¡Olé!".encode("cp1252").decode("utf-8")) # ¿Qué tal? ¡Olé!
twice = "año".encode().decode("cp1252").encode().decode("cp1252")
print(twice, "->", twice.encode("cp1252").decode("utf-8"),
"->", twice.encode("cp1252").decode("utf-8").encode("cp1252").decode("utf-8"))
año
¿Qué tal? ¡Olé!
año -> año -> año
Dos trampas. Primera: usa el mismo codec que lo rompió. Para texto roto por un lector cp1252 con comillas tipográficas, "don’t".encode("latin-1") falla con ordinal not in range(256), porque € y ™ no existen en Latin-1.
Segunda, y muy española: las mayúsculas acentuadas Á e Í. En UTF-8 son c3 81 y c3 8d, y los bytes 81 y 8d (junto con 8f, 90 y 9d) no están definidos en Windows-1252. El cp1252 de Python lanza un error; los navegadores, según el estándar de codificación de WHATWG, los convierten en caracteres de control invisibles (U+0081, etc.):
for s in ["ÁFRICA", "Íñigo"]:
b = s.encode()
try:
b.decode("cp1252")
except UnicodeDecodeError as e:
print(s, e, "| replace", repr(b.decode("cp1252", "replace")), "| latin-1", repr(b.decode("latin-1")))
ÁFRICA 'charmap' codec can't decode byte 0x81 in position 1: character maps to <undefined> | replace 'Ã�FRICA' | latin-1 'Ã\x81FRICA'
Íñigo 'charmap' codec can't decode byte 0x8d in position 1: character maps to <undefined> | replace 'Ã�ñigo' | latin-1 'Ã\x8dñigo'
Con replace la Á se pierde para siempre. Con Latin-1 (o el comportamiento de un navegador) el byte sigue ahí, invisible, y esta función de reparación maneja los dos casos:
def unmojibake(s, codec="cp1252"):
out = bytearray()
for ch in s:
try:
out += ch.encode(codec)
except UnicodeEncodeError:
if ord(ch) < 256: # U+0081 etc., lo que deja un decodificador tipo navegador
out.append(ord(ch))
else:
raise
return out.decode("utf-8")
Aplícala solo a texto que identificaste como doblemente codificado y revisa una muestra a ojo antes de correrla sobre una tabla entera.
PHP produce el mismo bug en una llamada, lo que ayuda a encontrarlo en código heredado. mb_convert_encoding("año", "UTF-8", "Windows-1252") sobre un string que ya era UTF-8 devolvió los bytes 61 c3 83 c2 b1 6f, que se ven como año. La llamada inversa, mb_convert_encoding("año", "Windows-1252", "UTF-8"), imprimió año. La versión clásica en bases de datos es una conexión MySQL configurada en latin1 mientras la aplicación envía UTF-8; eso lo describo por experiencia, no lo ejecutamos aquí. El lado del cliente SQL está en lo básico de SQL.
strict, replace, ignore y surrogateescape
Toda decodificación de Python acepta un argumento errors. Aquí están todos sobre los mismos bytes de año en Windows-1252 (61 f1 6f):
| errors= | Resultado | ¿Reversible? |
|---|---|---|
strict (por defecto) |
UnicodeDecodeError: 'utf-8' codec can't decode byte 0xf1 in position 1: invalid continuation byte |
no se decodifica nada |
replace |
'a�o' |
no |
ignore |
'ao' |
no, y en silencio |
backslashreplace |
'a\\xf1o' |
a mano |
surrogateescape |
'a\udcf1o' |
sí: .encode("utf-8", "surrogateescape") devolvió los bytes originales |
Fíjate en ignore: año se convirtió en ao, una palabra distinta, sin ningún aviso. Del lado de la codificación pasa lo mismo: "¡Olé! 👍 €" a Latin-1 con errors="replace" dio b'\xa1Ol\xe9! ? ?', y esos ? no se distinguen de un signo de interrogación real.
iconv en macOS
iconv convierte archivos desde la terminal. iconv -l lista los nombres que conoce; WINDOWS-1252, ISO-8859-1 y UTF-8 están ahí.
$ printf "año ¿Qué?\n" | iconv -f WINDOWS-1252 -t UTF-8 # dirección equivocada a propósito
año ¿Qué?
$ printf "año\n" | iconv -f UTF-8 -t WINDOWS-1252 | xxd # la reparación
00000000: 61c3 b16f 0a a..o.
$ printf "año\n" | iconv -f UTF-8 -t ASCII//TRANSLIT; echo "exit=$?"
iconv: warning: invalid characters: 1
a~no
exit=1
La segunda línea devuelve exactamente los bytes UTF-8 de año. La tercera muestra que //TRANSLIT en macOS no sirve para "quitar acentos": convirtió año en a~no, y con ¿Qué? se detuvo con Illegal byte sequence. -c descarta lo que no puede convertir, que es la versión de terminal de errors="ignore".
Error común: "arreglar" el error con replace o ignore
Error. Un proceso nocturno se cae con UnicodeDecodeError y alguien agrega errors="ignore" (o iconv -c) para que la importación pase.
Lo que pasa en realidad. El error desaparece y los datos pierden letras en silencio: año se vuelve ao, Muñoz se vuelve Muoz. Nadie lo nota hasta que un cliente reclama que su apellido salió mal en la factura. Con replace al menos se ve (a�o), pero el byte original igual se perdió.
PHP tiene su propia versión silenciosa. Pasarle UTF-8 inválido (aquí, un archivo en Windows-1252) a htmlspecialchars($s, ENT_QUOTES) devolvió un string vacío en PHP 8.5. Los flags por defecto desde PHP 8.1 incluyen ENT_SUBSTITUTE, y con ellos devolvió a�o. El código escrito antes de 8.1 suele pasar solo ENT_QUOTES, así que un único byte inválido deja el campo entero en blanco.
Solución. Averigua qué son los bytes en realidad (xxd, los patrones de arriba, la detección en la lección 6) y decodifica con eso. Si tienes que dejar pasar bytes desconocidos, usa surrogateescape, que conserva todo. Deja replace solo para mostrar, nunca para datos que vas a guardar.
El caso de Excel, que abre un CSV UTF-8 sin BOM como Windows-1252 en Windows en español y muestra año, está en la lección 4; Excel no se ejecutó aquí.
Cuándo ya no tiene arreglo: una medición
¿Qué mojibake es reversible? Generé 1,000 strings aleatorios de 20 caracteres por idioma (random.Random(42), caracteres tomados de una oración de muestra de cada idioma; la española incluye Álvaro, Íñigo y ÓSCAR), los codifiqué en UTF-8, los decodifiqué con el codec equivocado e intenté unmojibake:
rng = random.Random(42)
for _ in range(1000):
s = "".join(rng.choice(POOL) for _ in range(20))
raw = s.encode("utf-8")
try:
wrong = raw.decode(codec) # ¿la decodificación equivocada siquiera funciona?
except UnicodeDecodeError:
wrong = raw.decode(codec, handler) # lo que habría mostrado una herramienta tolerante
fixed += unmojibake(wrong, codec) == s
ja read as cp932 (replace): decoded without error 49/1000, repaired 49/1000
ko read as cp949 (replace): decoded without error 0/1000, repaired 0/1000
es read as cp1252 (replace): decoded without error 584/1000, repaired 584/1000
es read as cp1252 (c1 ): decoded without error 584/1000, repaired 1000/1000
es read as latin-1 (strict ): decoded without error 1000/1000, repaired 1000/1000
en read as cp1252 (replace): decoded without error 688/1000, repaired 688/1000
en read as cp1252 (c1 ): decoded without error 688/1000, repaired 1000/1000
En español, 416 de 1,000 strings tenían una Á o una Í. Decodificados al estilo de Python con replace, se perdieron; decodificados al estilo navegador (c1, un manejador de errores de una línea que conserva el byte), volvieron los 1,000:
import codecs
codecs.register_error("c1", lambda e: (chr(e.object[e.start]), e.start + 1))
Latin-1 define los 256 bytes, así que nunca pierde nada. En cambio, el japonés y el coreano leídos como cp932/cp949 casi siempre chocan con una secuencia inválida, y una vez que entra una �, la reparación falla. Copia el bucle, pon una muestra de tus propios datos en el pool y mide antes de prometerle a un cliente que el texto tiene arreglo.
Los casos irreversibles, en resumen:
- Cualquier
�que se guardó. Los bytes detrás ya no existen. - Cualquier
?escrito por un codificador conerrors="replace"(un emoji o€a Latin-1). - Lo que se descartó con
ignoreoiconv -c. - Lo que se "aproximó" con
//TRANSLIT(año→a~no).
Límites honestos y notas de versión
- Python 3.9.6, PHP 8.5.6 con mbstring e
iconvde macOS 26 (no tiene--version; no es GNU libiconv y sus mensajes difieren de los de Linux). No se ejecutó nada en Linux ni Windows. - En Windows, Python anterior a 3.15 abre los archivos de texto con la codificación del locale (cp1252 en un Windows en español) si no pasas
encoding=. La PEP 686 hace que UTF-8 sea el valor por defecto a partir de Python 3.15. Eso viene de la PEP; no se ejecutó aquí. - El comportamiento de los navegadores con los cinco bytes indefinidos viene del estándar de codificación de WHATWG; no se ejecutó ningún navegador. La doble codificación con MySQL
latin1está descrita, no ejecutada. ftfy, una biblioteca popular que automatiza esta reparación, no está instalada (no instalamos nada), así que no se compara.- La prueba de 1,000 strings toma caracteres de oraciones cortas, así que exagera letras poco frecuentes como
Á. Mide con tus datos.
Ejercicios
- En una tabla de clientes aparecen
Ñyá. ¿Qué eran y qué línea de Python los repara? - Un archivo empieza con los bytes
61 f1 6f. ¿En qué codificación está, qué dice y qué error da al leerlo como UTF-8? - ¿Por qué
"año".encode("latin-1").decode("utf-8")funciona, pero"don’t".encode("latin-1").decode("utf-8")falla?
Respuestas
Ñyá.s.encode("cp1252").decode("utf-8")convirtióÑenÑyáená.- Windows-1252 o Latin-1 (en los dos
f1es la ñ). Diceaño. Leído como UTF-8 daUnicodeDecodeError: 'utf-8' codec can't decode byte 0xf1 in position 1: invalid continuation byte. Ãy±existen en Latin-1, así que vuelven los bytesc3 b1.€y™no existen en Latin-1, así que el encode falla conordinal not in range(256); usacp1252(ounmojibake) para texto roto por un decodificador Windows-1252.
Siguiente
A veces los bytes son UTF-8 perfecto en los dos lados y aun así los strings no coinciden: "José" escrito en una Mac y "José" escrito en Windows pueden ser distintos. Eso es la normalización Unicode.
- Anterior: Curso de texto y codificación 1/6: caracteres, code points y bytes UTF-8
- Siguiente: Curso de texto y codificación 3/6: normalización Unicode, NFC/NFD y nombres de archivo
- Más adelante: CSV, BOM y Excel, longitud de strings y emoji, detección y rendimiento
- Relacionado: cabeceras HTTP y el charset de Content-Type (en inglés), URLs no ASCII en .htaccess con LiteSpeed (en inglés), la hoja de ruta para aprender programación
Preguntas frecuentes
¿Por qué la palabra año aparece como año?
El texto se guardó en UTF-8, donde la ñ son los dos bytes C3 B1, y luego se leyó como Windows-1252 (o Latin-1), que muestra C3 como à y B1 como ±. No se perdió nada: 'año'.encode('cp1252').decode('utf-8') devolvió 'año'.
¿Por qué Álvaro o ÁFRICA no se pueden reparar a veces?
La Á en UTF-8 es C3 81, y el byte 81 no está definido en Windows-1252. El cp1252 de Python lanza un error con ese byte; con errors='replace' guarda 'Ã�FRICA' y la información se pierde. Si se decodificó como Latin-1 (que define los 256 bytes), sí se puede reparar.
¿Uso errors='ignore' para que deje de salir UnicodeDecodeError?
Solo si perder datos te da igual. errors='ignore' convirtió 'año' guardado en Windows-1252 en 'ao' sin avisar. Averigua la codificación real y decodifica con ella, o usa errors='surrogateescape' si tienes que pasar bytes desconocidos sin cambiarlos.