Cuando los bytes se disfrazan de texto: Unicode, Python y errores de codificación

Un fichero contiene José, pero después de pasar por un pipeline aparece José. Otro conserva las tildes, aunque una búsqueda de Jose devuelve también José. Y en un tercero los emojis se convierten en ?.
Los tres casos parecen problemas de texto, pero conviene investigar causas distintas: cómo interpretamos los bytes, cómo comparamos las cadenas y qué información se ha perdido durante el recorrido. Cambiar a UTF-8 sin localizar el fallo puede limitarse a guardar correctamente un texto que ya estaba estropeado.
Qué es una codificación y por qué la necesitamos
Un fichero no almacena una ñ dibujada: almacena bytes. Para convertir esos números en texto necesitamos un acuerdo que establezca qué secuencias representan cada carácter. Ese acuerdo es una codificación de caracteres.
Podemos imaginarla como las reglas de escritura y lectura de un mensaje. Emisor y receptor deben compartirlas: recibir los mismos bytes no garantiza leer el mismo texto si cada lado utiliza reglas diferentes.
Las codificaciones nacen de esa necesidad de representar y transmitir texto con máquinas. El espacio disponible, los idiomas y la compatibilidad con equipos existentes condicionaron las soluciones. El modelo de codificación de Unicode distingue el repertorio de caracteres, sus códigos y las formas de llevarlos a unidades de almacenamiento.
Un ejemplo verificable en Python muestra por qué los bytes necesitan contexto:
print("ñ".encode("utf-8").hex()) # c3b1
print("ñ".encode("latin-1").hex()) # f1
Ambas secuencias representan la misma letra bajo su codificación correspondiente. Ninguna lleva una etiqueta que diga, por sí sola, cómo hay que leerla.
De ASCII a un mundo con muchos alfabetos
Aunque asociemos ASCII a los ordenadores de los ochenta, viene de los años sesenta. El RFC 20, publicado en 1969, ya lo proponía para intercambiar información en red. Sus siete bits permiten 128 valores: letras latinas sin tildes, cifras, signos y caracteres de control. La ñ, la á y los ideogramas chinos quedan fuera.
Para cubrir más idiomas aparecieron otras tablas, como ISO-8859-1 y Windows-1252, junto a codificaciones para otros sistemas de escritura. En las tablas de un byte había poco espacio y cada entorno lo aprovechaba de manera distinta. Lo que servía para un idioma podía resultar insuficiente para otro; intercambiar ficheros entre sistemas exigía conocer la tabla de origen.
ISO-8859-1, conocido como Latin-1, y Windows-1252 no son intercambiables: difieren en el rango 0x80–0x9F. Por ejemplo, 0x80 representa € en Windows-1252 y un control en Latin-1. Python incluye ambos códecs por separado en su lista de codificaciones.
Unicode surgió para construir un repertorio común. Sus primeras conversaciones comenzaron en 1987 entre ingenieros de Xerox y Apple, y el consorcio se constituyó en 1991, según su historia oficial. La intención era poder mezclar idiomas sin cambiar de tabla cada vez.
Unicode, UTF-8 y UTF-16 son conceptos relacionados
Unicode asigna puntos de código a los caracteres. Por ejemplo, ñ corresponde a U+00F1. UTF-8 y UTF-16 especifican cómo representar ese texto mediante unidades que se pueden almacenar y transmitir. No hay una secuencia histórica «ASCII, después UTF-8, después UTF-16, después Unicode»: las dos UTF son formas de representar Unicode.
| Concepto | Función | Ejemplo para ñ |
|---|---|---|
| Unicode | Identificar el carácter con un punto de código | U+00F1 |
| UTF-8 | Usar de uno a cuatro bytes por valor escalar Unicode | C3 B1 |
| UTF-16LE | Usar una o dos unidades de 16 bits, con bytes en orden little-endian | F1 00 |
UTF-8 conserva los valores ASCII. UTF-16 necesita un par de unidades para caracteres como 😀: no significa «dos bytes para cualquier carácter». Al serializar UTF-16 también importa el orden de los bytes, indicado por LE, BE o, según el formato, una marca BOM. La FAQ de Unicode sobre las UTF explica estas diferencias.
Por qué Unicode importa en Python
En Python 3, str representa texto Unicode y bytes representa bytes. Mantener esa frontera clara permite procesar nombres, idiomas y símbolos sin convertirlos continuamente a una tabla local. Una cadena str no es un fichero UTF-8 en miniatura. La guía Unicode de Python desarrolla esta separación.
texto = "España 😀" # str
datos = texto.encode("utf-8") # bytes
recuperado = datos.decode("utf-8") # str
assert recuperado == texto
assert len(texto) == 8
assert len(datos) == 12
La longitud de str cuenta puntos de código; la de bytes, bytes. Tampoco equivale siempre al número de símbolos visibles: una letra con un acento combinante o un emoji compuesto puede ocupar varios puntos de código. La referencia de los tipos de Python describe estas secuencias.
La regla práctica es decodificar al entrar, trabajar con texto y codificar al salir:
bytes de origen ── decode(origen) ──► str de Python
str de Python ── encode(destino) ─► bytes de destino
decode lleva los bytes a texto; encode lleva el texto a bytes. Representar ese texto en pantalla es trabajo del sistema de salida y del renderizador. Si una librería ya devuelve str, esa frontera puede estar resuelta: comprueba el tipo antes de añadir otra conversión.
Cómo lidiar con un error: recuperar el origen e interpretar bien
Ante bytes sin interpretar, necesitamos decode, con la codificación real de origen. Volver a la representación binaria original ayuda a investigar, pero no hay una «codificación binaria más básica» que elimine la necesidad de saber cómo se escribieron esos bytes.
Supongamos que recibimos un nombre de un sistema que exporta Windows-1252 y debemos entregarlo en UTF-8:
origen = b"Jos\xe9" # bytes recibidos del sistema antiguo
texto = origen.decode("cp1252") # "José"
salida = texto.encode("utf-8") # b'Jos\xc3\xa9'
Primero interpretamos el origen; después serializamos para el destino. Si el fichero es grande, podemos dejar que la entrada y salida de texto hagan esa conversión:
with open("entrada.csv", encoding="cp1252", errors="strict") as entrada:
with open("salida.csv", "w", encoding="utf-8", errors="strict") as salida:
for linea in entrada:
salida.write(linea)
Especificar encoding evita depender de los valores por defecto del entorno. Los modos de texto y binario están documentados en open.
Si ya tenemos José
Este fenómeno se llama mojibake: unos bytes se han interpretado con reglas equivocadas. Aquí podemos reproducir una causa concreta:
original = "José".encode("utf-8")
mal = original.decode("latin-1")
assert mal == "José"
# Solo porque conocemos la conversión equivocada y no hubo pérdida:
bytes_recuperados = mal.encode("latin-1")
bien = bytes_recuperados.decode("utf-8")
assert bien == "José"
En esta reparación aparece primero un encode para deshacer una decodificación errónea conocida. No contradice la regla de entrada: estamos reconstruyendo bytes que ya se habían convertido a texto. Aplicar encode(...).decode(...) indiscriminadamente no es una solución general. Si el error utilizó Windows-1252, habría que estudiar esa transformación, no asumir Latin-1.
Tampoco basta con que una decodificación no lance una excepción. Latin-1 asigna un carácter a cada valor de byte: puede aceptar datos que producen un texto absurdo. Los detectores automáticos ofrecen hipótesis; el contrato de la fuente, sus metadatos y muestras conocidas ofrecen mejores evidencias.
Si se sustituyeron caracteres por ? o �, o se eliminaron con errors="ignore", puede haberse perdido información. Para recuperar el valor original habrá que volver a una copia anterior. En un pipeline, propongo fallar o poner el registro en cuarentena antes que aceptar una modificación silenciosa. Esa es una decisión de calidad de datos que debe quedar explícita.
En una base de datos: almacenamiento y collation
La codificación responde a «¿cómo represento el texto?». Una collation responde a «¿cómo ordeno y comparo este texto?». Define reglas lingüísticas y sensibilidades, como distinguir mayúsculas o acentos.
Un nombre puede estar almacenado perfectamente y aun así producir resultados inesperados en WHERE, JOIN, ORDER BY, GROUP BY, DISTINCT o una restricción UNIQUE. Si la comparación considera dos nombres equivalentes, pueden agruparse o colisionar aunque sus caracteres difieran. El efecto depende del motor y de la collation concreta.
En MySQL, los sufijos ci y cs indican insensibilidad y sensibilidad a mayúsculas; ai y as, a acentos. Así lo define su documentación sobre nombres de collation. Este ejemplo corresponde a MySQL 8.0/8.4:
SELECT
_utf8mb4'José' COLLATE utf8mb4_0900_ai_ci = _utf8mb4'jose'
AS iguales; -- 1
SELECT
_utf8mb4'José' COLLATE utf8mb4_0900_as_cs = _utf8mb4'jose'
AS iguales; -- 0
Para admitir todo el repertorio Unicode en MySQL, utiliza utf8mb4, no el antiguo utf8mb3, limitado a caracteres representables en hasta tres bytes. La documentación de utf8mb4 explica por qué un emoji como 😀 necesita el primero. Comprueba también la configuración de la conexión, no solo la columna.
En SQL Server, la collation también determina la página de códigos de varchar cuando no usa UTF-8. Desde SQL Server 2019, las collations con _UTF8 permiten UTF-8 en varchar; nvarchar utiliza UCS-2 o UTF-16 según la collation y el soporte de caracteres suplementarios. Los literales Unicode se escriben como N'José'. Véase collation y soporte Unicode en SQL Server.
Cómo elegir la collation adecuada
Parte de las reglas del producto. Un buscador de nombres puede necesitar encontrar José al escribir jose. Un identificador puede exigir que ABC y abc sean distintos. La búsqueda y la unicidad no tienen por qué compartir exactamente las mismas reglas.
Prueba nombres reales de los idiomas admitidos: José/Jose, ABC/abc, Peña/Pena, además de casos de turco o alemán si corresponden. No asumas que todas las collations tratan la ñ como una n con un acento prescindible: las reglas lingüísticas importan.
Antes de cambiarla, revisa duplicados bajo la nueva comparación, índices, restricciones y consultas. Aplicar COLLATE en una consulta puede resolver una comparación puntual, pero conviene comprobar su plan de ejecución. Y cambiar la collation no reconstruye un José que ya se guardó como José.
Cómo depurar el fallo dentro de un pipeline
La pregunta útil es: ¿en qué primera frontera cambia el valor? Sigue una muestra conocida durante todo el recorrido:
CSV → lectura en Python → transformación → base de datos → API → navegador
Conserva los bytes de origen y registra, para una muestra sintética, el tipo y la representación exacta antes y después de cada frontera. Este pequeño inspector evita depender de cómo dibuja los caracteres una consola:
def inspeccionar(valor):
if isinstance(valor, bytes):
return {"tipo": "bytes", "longitud": len(valor), "hex": valor.hex()}
if isinstance(valor, str):
return {
"tipo": "str",
"longitud": len(valor),
"escaped": ascii(valor),
"puntos": [f"U+{ord(c):04X}" for c in valor],
}
raise TypeError("Se esperaba str o bytes")
print(inspeccionar("José"))
print(inspeccionar("José".encode("utf-8")))
En producción, limita y protege estas muestras: no hace falta volcar datos personales en los logs. Añade el origen, el códec utilizado y la etapa para poder reproducir la transformación.
| Síntoma | Hipótesis que comprobar |
|---|---|
José | UTF-8 leído como otra tabla o una transformación repetida |
UnicodeDecodeError | Códec equivocado, bytes dañados o una secuencia multibyte incompleta |
UnicodeEncodeError | La codificación de salida no admite algún carácter |
� o ? inesperados | Sustitución con pérdida en alguna etapa |
| Cuadrados vacíos, pero puntos de código correctos | Falta de glifos en la fuente |
| Datos correctos, resultados de búsqueda inesperados | Collation o reglas de normalización |
Comprueba el BOM: utf-8-sig permite consumir una marca UTF-8 si el origen la incluye. Si el contrato dice UTF-16, revisa su orden de bytes. Una cabecera HTTP que anuncia un charset distinto de los bytes enviados también merece atención. En JSON, "Jos\u00e9" puede ser una representación escapada perfectamente válida; compara el valor después de parsear, no solo el fichero a simple vista. El RFC 8259 define los escapes y el uso de UTF-8 para intercambiar JSON entre sistemas.
Si lees en bloques, un carácter puede quedar partido entre dos lecturas. Usa un lector de texto o un decodificador incremental, que conserva los bytes pendientes:
import codecs
decoder = codecs.getincrementaldecoder("utf-8")(errors="strict")
assert decoder.decode(b"\xc3") == ""
assert decoder.decode(b"\xb1", final=True) == "ñ"
No interpretes un bloque incompleto como prueba de que el fichero utiliza otra codificación.
Prevenirlo con pruebas de datos
Una prueba con Alice o Madrid apenas ejercita estas fronteras. Propongo un pequeño corpus con tildes, ñ, €, comillas tipográficas, alfabetos no latinos, emojis y caracteres combinantes. Estas pruebas unitarias sirven como base y se pueden ejecutar con pytest:
import unicodedata
import pytest
@pytest.mark.parametrize("texto", [
"José", "España", "€", "“hola”", "東京", "العربية", "😀", "e\u0301",
])
def test_utf8_sin_perdida(texto):
assert texto.encode("utf-8").decode("utf-8") == texto
def test_entrada_legacy_con_valor_esperado():
assert b"Jos\xe9".decode("cp1252") == "José"
def test_utf8_invalido_se_rechaza():
with pytest.raises(UnicodeDecodeError):
b"\xff".decode("utf-8", errors="strict")
def test_normalizacion_si_el_contrato_exige_nfc():
assert unicodedata.normalize("NFC", "e\u0301") == "é"
La normalización Unicode resuelve representaciones equivalentes, como é y e más acento combinante; no repara mojibake. unicodedata.normalize permite aplicar NFC cuando el contrato lo exige. No elimines acentos del dato original para facilitar una búsqueda.
El round trip UTF-8 es una comprobación básica: también lo pasaría José. La prueba decisiva recorre el pipeline real: leer un fichero de prueba con bytes conocidos, ejecutar la transformación, insertar con el mismo driver y esquema que producción, recuperar mediante la API y comparar con valores esperados independientes. Incluye la ruta de streaming y sus límites entre bloques si existe.
Añade casos de búsquedas y unicidad bajo la collation elegida, rechazo o cuarentena de entradas inválidas y ausencia de sustituciones introducidas por el proceso. Para el origen, documenta codificación y política de BOM; para el procesamiento, mantén texto Unicode; para cada salida, fija la serialización. Cuando esos acuerdos están escritos y probados, el próximo José deja de ser un misterio y se convierte en una frontera concreta que podemos corregir.
