Cuando trabajas con texto en Python 3, hay una frontera invisible que cruzas constantemente sin darte cuenta: la frontera entre str y bytes. Un str es una secuencia de code points Unicode — abstracciones que representan caracteres sin importar cómo se almacenan. Un bytes es una secuencia de octetos crudos. El encoding es la tabla que define cómo traducir entre esos dos mundos.
UTF-8 domina la web y los archivos modernos por razones concretas: es compatible con ASCII para los primeros 128 code points, usa longitud variable (1 a 4 bytes por carácter), y puede representar cualquier carácter Unicode. Pero en sistemas heredados vas a encontrar UTF-16 (común en Windows y Java internamente), Latin-1 (ISO-8859-1, el byte-a-code-point más directo que existe) y CP1252 (la extensión de Microsoft de Latin-1, con caracteres adicionales en el rango 0x80–0x9F que Latin-1 deja sin asignar).
El problema aparece cuando decodificas bytes usando un encoding incorrecto — o cuando intentas codificar un carácter que ese encoding no puede representar. Python no adivina: lanza UnicodeDecodeError o UnicodeEncodeError con suficiente información para diagnosticar el problema, si sabes leerlos. El error más silencioso y peligroso no es el que explota, sino el que no especificas encoding al abrir un archivo y confías en locale.getpreferredencoding() — que en Windows puede ser CP1252 y en Linux UTF-8, haciendo que el mismo código se comporte diferente según la máquina.
# encoding_demo.py
from pathlib import Path
# ── 1. Encode: str → bytes ──────────────────────────────────────────────
greeting = "Héroe del café ☕"
utf8_bytes = greeting.encode("utf-8")
latin1_fail = None
try:
# ☕ (U+2615) no existe en Latin-1: solo cubre U+0000–U+00FF
latin1_fail = greeting.encode("latin-1")
except UnicodeEncodeError as exc:
print(f"[encode error] {exc}")
# 'latin-1' codec can't encode character '\u2615' in position 15: ...
# Estrategias de manejo cuando no puedes cambiar el encoding destino
ignore_bytes = greeting.encode("latin-1", errors="ignore")
replace_bytes = greeting.encode("latin-1", errors="replace")
backslash_bytes = greeting.encode("latin-1", errors="backslashreplace")
print(repr(ignore_bytes)) # b'Hroe del caf ' — el carácter desaparece
print(repr(replace_bytes)) # b'Hr\xf3e del caf\xe9 ?' — ? para lo no representable
print(repr(backslash_bytes)) # b'Hr\xf3e del caf\xe9 \\u2615'
# ── 2. Decode: bytes → str ──────────────────────────────────────────────
cp1252_bytes = bytes([0x48, 0xe9, 0x72, 0x6f, 0x65]) # "Héroe" en CP1252
decoded_correct = cp1252_bytes.decode("cp1252") # ✓ "Héroe"
decoded_latin1 = cp1252_bytes.decode("latin-1") # también "Héroe": CP1252 y Latin-1
# coinciden en este rango
# bytes que son válidos en CP1252 pero inválidos en UTF-8
windows_specific = bytes([0x93, 0x94]) # " " (comillas tipográficas) en CP1252
try:
windows_specific.decode("utf-8")
except UnicodeDecodeError as exc:
print(f"[decode error] {exc}")
# invalid start byte
recovered = windows_specific.decode("cp1252") # '\u201c\u201d' — correcto
# ── 3. Archivos: siempre encoding explícito ─────────────────────────────
sample_path = Path("/tmp/demo_encoding.txt")
# Escribir con encoding explícito
sample_path.write_text("Precio: 10€\n", encoding="utf-8")
# ❌ Peligro: abrir sin encoding depende del sistema operativo
# with open(sample_path) as f: # usa locale.getpreferredencoding()
# content = f.read() # CP1252 en Windows rompe el € (U+20AC)
# ✅ Siempre explícito
with open(sample_path, encoding="utf-8") as f:
content = f.read()
print(repr(content)) # 'Precio: 10€\n'
# ── 4. Detectar encoding de bytes desconocidos ──────────────────────────
# chardet/charset-normalizer son heurísticas — no infalibles.
# Úsalos solo cuando el emisor no documenta el encoding.
try:
import charset_normalizer
result = charset_normalizer.from_bytes(cp1252_bytes).best()
print(f"Detected: {result.encoding if result else 'unknown'}")
except ImportError:
print("charset-normalizer no instalado; pip install charset-normalizer")
# ── 5. BOM: el marcador que rompe parsers desprevenidos ─────────────────
utf8_bom = greeting.encode("utf-8-sig") # incluye BOM al inicio (0xEF 0xBB 0xBF)
no_bom = greeting.encode("utf-8")
# Si lees un archivo con BOM usando "utf-8" en lugar de "utf-8-sig",
# el BOM aparece como el carácter U+FEFF al inicio del string
print(utf8_bom[:3].hex()) # efbbbf — esos tres bytes son el BOM
print(no_bom[:3].hex()) # c3 88 72 — empieza directo en 'H' con acento
Lo que hace cada decisión
La asimetría entre Latin-1 y CP1252 es la trampa más frecuente en datos de Windows. Los bytes 0x80–0x9F son indefinidos en Latin-1 pero CP1252 los asigna a caracteres tipográficos como comillas " " y …. Decodificar esos archivos como Latin-1 produce objetos Python sin excepción pero con code points incorrectos — corrupción silenciosa.
errors='ignore' es casi siempre la opción incorrecta en producción. Hace desaparecer información sin advertencia. Tiene sentido en logs de monitoreo donde prefieres datos incompletos a una excepción, pero no en documentos financieros o identificadores de usuario.
errors='replace' sustituye caracteres no representables por ? al codificar y por el carácter de reemplazo Unicode (U+FFFD, \ufffd) al decodificar. Útil para mostrar contenido al usuario cuando la fidelidad exacta no es crítica.
errors='backslashreplace' emite la representación \uXXXX literal como bytes ASCII. Es la opción correcta para logging y debugging: preserva información legible sin explotar.
El BOM (utf-8-sig) merece atención especial porque Excel genera CSV con BOM para señalar UTF-8, algo que muchos parsers no esperan. Si tu pipeline lee archivos generados en Windows y el primer campo aparece como \ufeffnombre en vez de nombre, ahí está el culpable — usa encoding="utf-8-sig" y el BOM se consume automáticamente.
La detección automática via charset_normalizer (o el más antiguo chardet) funciona con textos largos y suficiente entropía lingüística, pero falla en cadenas cortas o datos binarios disfrazados de texto. Cuando el sistema de origen no documenta el encoding, la estrategia más robusta es implementar un pipeline que marque esos archivos como “encoding desconocido” y los enrute a revisión manual antes de procesarlos.
Errores que debes conocer
Error: Abrir un archivo sin encoding explícito en sistemas Windows, donde getpreferredencoding() retorna cp1252, mientras que el archivo fue guardado en UTF-8.
# ❌ Wrong
with open("datos.csv") as f: # CP1252 en Windows, UTF-8 en Linux
data = f.read() # comportamiento no portátil
# ✅ Right
with open("datos.csv", encoding="utf-8") as f:
data = f.read()
encoding="utf-8" hace el comportamiento determinista en cualquier plataforma.
Error: Mezclar bytes y str sin conversión explícita — Python 3 no concatena tipos diferentes, pero el error aparece lejos del origen si acumulas bytes y cadenas en estructuras de datos.
# ❌ Wrong
header = b"Content-Type: text/plain\r\n"
body = "Héroe del café"
# message = header + body # TypeError: can't concat str to bytes
# ✅ Right
message = header + body.encode("utf-8")
Define en qué “capa” vive cada variable — bytes en el transporte, str en la lógica de negocio — y convierte en la frontera, no en el medio.
Error: Usar errors='ignore' para “arreglar” un UnicodeDecodeError sin entender qué bytes se están descartando.
# ❌ Wrong — silencia el síntoma, corrompe los datos
text = raw_bytes.decode("utf-8", errors="ignore")
# ✅ Right — diagnostica primero, luego decide
try:
text = raw_bytes.decode("utf-8")
except UnicodeDecodeError as exc:
print(f"Bytes problemáticos en posición {exc.start}: {raw_bytes[exc.start:exc.end].hex()}")
# Decide aquí: ¿CP1252? ¿Latin-1? ¿datos corruptos?
text = raw_bytes.decode("cp1252") # o el encoding correcto
El UnicodeDecodeError incluye exc.start, exc.end y exc.reason — suficiente para identificar qué encoding debías usar.
N° 39