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 Â.

Diagrama de decisión. Empieza en el texto roto. Pregunta uno: ¿aparecen letras de más como ñ, é o ¿? Si sí, se decodificó UTF-8 como cp1252 o Latin-1, y s.encode('cp1252').decode('utf-8') lo repara. Si no, pregunta dos: ¿ves el carácter de reemplazo o un UnicodeDecodeError? Si sí, se decodificaron bytes de Windows-1252 como UTF-8, así que vuelve a leer los bytes originales con la codificación correcta. Si el carácter de reemplazo o un signo de interrogación ya se guardó, el texto está perdido.
Cómo leer el mojibake. Si aparecen letras extrañas de más, se leyó UTF-8 como una codificación antigua y casi siempre se puede revertir. Si ves el carácter de reemplazo o un error, se leyeron bytes antiguos como UTF-8: vuelve a los bytes originales.

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
Gráfico de barras de strings reparados de 1,000. Japonés leído como cp932 con reemplazo: 49. Coreano leído como cp949 con reemplazo: 0. Español leído como cp1252 con reemplazo: 584. Español leído como cp1252 conservando los bytes indefinidos: 1,000. Español leído como Latin-1: 1,000. Inglés leído como cp1252 con reemplazo: 688. Inglés leído como cp1252 conservando los bytes indefinidos: 1,000.
1,000 strings aleatorios de 20 caracteres por fila (seed 42), codificados en UTF-8, decodificados con la codificación equivocada y luego reparados. Solo los decodificadores que conservan todos los bytes permiten reparar todo. Python 3.9.

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:

  1. Cualquier � que se guardó. Los bytes detrás ya no existen.
  2. Cualquier ? escrito por un codificador con errors="replace" (un emoji o € a Latin-1).
  3. Lo que se descartó con ignore o iconv -c.
  4. 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 iconv de 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 latin1 está 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

  1. En una tabla de clientes aparecen Ñ y á. ¿Qué eran y qué línea de Python los repara?
  2. 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?
  3. ¿Por qué "año".encode("latin-1").decode("utf-8") funciona, pero "don’t".encode("latin-1").decode("utf-8") falla?

Respuestas

  1. Ñ y á. s.encode("cp1252").decode("utf-8") convirtió Ñ en Ñ y á en á.
  2. Windows-1252 o Latin-1 (en los dos f1 es la ñ). Dice año. Leído como UTF-8 da UnicodeDecodeError: 'utf-8' codec can't decode byte 0xf1 in position 1: invalid continuation byte.
  3. Ã y ± existen en Latin-1, así que vuelven los bytes c3 b1. € y ™ no existen en Latin-1, así que el encode falla con ordinal not in range(256); usa cp1252 (o unmojibake) 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.

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.

  • #codificación
  • #principiante
  • #mojibake
  • #UTF-8
  • #Windows-1252
  • #Python

t.laboratory

A technical blog that records real problems from the apps, servers and services we build and run — and the code that fixed them.

Acerca de · Apps

↑