En Python, el texto es str y los datos binarios son bytes. Esa es la respuesta inicial para casi cualquier duda de codificación: conserva str dentro del programa, llama a encode() al cruzar hacia una frontera binaria y usa decode() cuando los bytes recibidos vuelven a ser texto. UTF-8 suele ser la codificación elegida para la conversión; no es un tipo de cadena.

La frontera existe al leer archivos, transmitir HTTP, consultar bases de datos y transportar JSON. Confundir ambos dominios provoca excepciones como UnicodeDecodeError o defectos silenciosos como João. Con un modelo preciso, el diagnóstico deja de ser una sucesión de cambios aleatorios para “arreglar acentos”.

Unicode no es UTF-8

Unicode es un repertorio que asigna puntos de código a caracteres. A es U+0041 y ç es U+00E7. Una str representa caracteres Unicode con independencia de cómo se guarden. La guía de strings en Python explica las operaciones textuales habituales.

UTF-8 convierte esos puntos en octetos. Los caracteres ASCII ocupan un byte; muchos caracteres latinos, dos; otros necesitan tres o cuatro. Por eso no coinciden la longitud textual y el tamaño binario:

texto = "ação"
datos = texto.encode("utf-8")

print(len(texto))       # 4 caracteres
print(len(datos))       # 6 bytes
print(datos)            # b'a\xc3\xa7\xc3\xa3o'
print(datos.decode("utf-8") == texto)  # True

El prefijo b marca un literal de bytes. Secuencias como \xc3 muestran valores hexadecimales, no texto dañado. El Unicode HOWTO oficial desarrolla puntos de código, representaciones y normalización.

Encode al salir, decode al entrar

str.encode() devuelve bytes; bytes.decode() devuelve str. Una regla robusta consiste en decodificar una vez, inmediatamente después de recibir datos binarios, y codificar una vez justo antes de enviarlos. La lógica de negocio permanece textual.

def crear_mensaje(nombre: str) -> bytes:
    texto = f"Hola, {nombre}!"
    return texto.encode("utf-8")

paquete = crear_mensaje("Lívia")
mensaje = paquete.decode("utf-8")
print(mensaje)

No uses str(datos) para decodificar: genera una representación como "b'hola'". El codec debe proceder del contrato del protocolo. La referencia de bytes y bytearray documenta las operaciones binarias y su mutabilidad.

Archivos y JSON

En modo texto, open realiza la conversión. Declara encoding="utf-8" para evitar que el resultado dependa de la configuración regional del equipo:

from pathlib import Path

ruta = Path("clientes.txt")
ruta.write_text("Ana\nJosé\n", encoding="utf-8")
contenido = ruta.read_text(encoding="utf-8")
print(contenido.splitlines())

Lo mismo vale para open(ruta, "w", encoding="utf-8", newline=""). Consulta archivos TXT, CSV y JSON con Python y pathlib en Python para trabajar con formatos y rutas.

JSON es texto. json.dumps() devuelve str y json.loads() suele recibir str. ensure_ascii=False conserva los caracteres legibles; codifica después solo si el transporte requiere bytes:

import json

registro = {"ciudad": "São Luís", "moneda": "€"}
texto_json = json.dumps(registro, ensure_ascii=False)
cuerpo_http = texto_json.encode("utf-8")

restaurado = json.loads(cuerpo_http.decode("utf-8"))
assert restaurado == registro

La guía de JSON en Python amplía la validación y serialización.

Diagnosticar errores sin adivinar

UnicodeEncodeError indica que el codec de destino no representa un carácter; ASCII no admite ã. UnicodeDecodeError indica que una secuencia no es válida según la codificación elegida. La excepción informa codec, posición y causa:

datos_latin1 = b"Ol\xe1"

try:
    print(datos_latin1.decode("utf-8"))
except UnicodeDecodeError as error:
    print(error.encoding, error.start, error.reason)

print(datos_latin1.decode("latin-1"))  # Olá

La solución correcta es establecer la procedencia mediante el encabezado HTTP, la especificación del archivo, el contrato del proveedor o la conexión de base de datos. Un detector ofrece probabilidades, no una prueba. Si controlas ambos extremos, estandariza UTF-8.

Los manejadores errors="ignore" y errors="replace" evitan la excepción perdiendo información. Resultan peligrosos para nombres, identificadores, firmas y auditoría. Conserva strict, el valor predeterminado. En una importación heredada cuya pérdida esté aprobada, replace introduce y permite medir el daño; backslashreplace puede conservar una representación útil para logs. La documentación de codecs de Python enumera las opciones.

Mojibake y doble codificación

João suele significar que bytes UTF-8 se decodificaron como Latin-1 y luego se almacenó el texto equivocado. Las sustituciones manuales no son fiables. Hay que reprocesar los bytes originales con el codec real. Si solo queda la cadena dañada y se conoce exactamente la transformación, es posible ensayar una recuperación controlada:

dañado = "João"
recuperado = dañado.encode("latin-1").decode("utf-8")
print(recuperado)  # João

No es una fórmula universal. Crea una copia, valida muestras y registra cambios. Aplicarla a texto correcto genera una corrupción nueva.

Normalización y comparación segura

El é visible puede ser un punto de código o una e seguida de acento combinante. Se muestran igual, pero sus bytes y comparaciones difieren. Normaliza entradas procedentes de fuentes distintas:

import unicodedata

a = "café"
b = "cafe\u0301"
print(a == b)  # False
print(unicodedata.normalize("NFC", a) == unicodedata.normalize("NFC", b))  # True

NFC es una política habitual de almacenamiento. NFKC aplica además equivalencias de compatibilidad y puede cambiar significado; úsala solo con un requisito claro. Para búsquedas sin distinción de mayúsculas, casefold() abarca más casos que lower(), aunque una decisión de autorización no debe basarse únicamente en la apariencia: distintos alfabetos contienen caracteres visualmente confundibles.

Prácticas, seguridad y observabilidad

Declara la codificación en contratos de archivos y servicios. Para diagnosticar, no registres secretos ni cuerpos completos: anota origen, codec declarado, posición y una muestra hexadecimal limitada. Restringe el tamaño antes de decodificar porque una entrada enorme puede agotar memoria. Después valida estructura y semántica; ser UTF-8 válido no convierte un dato externo en seguro.

Deja el percent-encoding de URLs y los charsets HTTP a bibliotecas maduras. Comprueba cliente, esquema y columnas Unicode de la base. Prueba acentos, formas combinantes, escrituras no latinas y caracteres suplementarios. Try/except en Python ayuda a situar la captura correctamente y logging en Python permite conservar contexto sin ocultar fallos.

Preguntas frecuentes

¿Qué diferencia existe entre str y bytes?

str contiene texto Unicode; bytes, enteros de 0 a 255. Codificar transforma texto en bytes y decodificar ejecuta la operación inversa.

¿Debo usar siempre UTF-8?

Úsalo de forma predeterminada cuando controles el contrato. Al consumir datos externos, respeta su codificación declarada en lugar de imponer UTF-8 a bytes heredados.

¿Puedo solucionar la excepción con errors="ignore"?

Solo la oculta eliminando datos. Úsalo cuando esa pérdida sea explícitamente aceptable y medible; los datos de negocio deberían fallar de forma visible para corregir el origen.

¿Por qué el texto aparece como b'...'?

El valor aún es bytes o se convirtió su representación mediante str(). Decodifica los bytes originales una única vez con el codec correcto.

Conclusión

Unicode modela caracteres, UTF-8 los representa como bytes, encode() prepara texto para una frontera binaria y decode() interpreta los bytes entrantes. Conserva str en el núcleo, especifica codecs en cada límite y evita opciones que borren evidencias. Ante un fallo, preserva los bytes originales, identifica el contrato y corrige la frontera, no los síntomas visibles.