Codificación
Codificación de caracteres para developers — ASCII, UTF-8, UTF-16
La codificación de caracteres confunde a la gente décadas después de estar resuelta. El modelo mental: code points, encodings, BOMs, y por qué UTF-8 casi siempre gana.
La codificación de caracteres debería ser un problema resuelto en 2026, y para la mayoría lo es — casi todo es UTF-8. Pero el modelo mental sigue importando porque el 5% de casos en que algo falla (BOMs, exports legacy, diálogos de archivo de Windows, librerías cliente de BD) te come horas a la semana si no conoces la forma del problema. Esta guía es la base.
Los dos conceptos separados
Fuente de confusión principal: “Unicode” y “UTF-8” son cosas distintas.
- Unicode es un estándar que asigna a cada carácter un entero único llamado code point, escrito
U+XXXX.Aes U+0041.ées U+00E9. El emoji de caca es U+1F4A9. Hay más de un millón de code points posibles; unos 150.000 están asignados a fecha de Unicode 16. - Un encoding es una forma de convertir code points en bytes. UTF-8, UTF-16, UTF-32 y encodings legacy como Latin-1, Shift-JIS, Windows-1252 todos encodean (algún subconjunto de) caracteres en bytes.
Unicode es el set de caracteres. UTF-8 es un encoding de ese set. Puedes tener Unicode encodeado en UTF-8, Unicode encodeado en UTF-16, o los mismos caracteres como bytes Windows-1252 legacy. Mismos caracteres, bytes distintos en disco.
ASCII, el ancestro común
ASCII es un encoding de 7 bits definido en 1963. Cubre 128 code points: letras latinas, dígitos, puntuación, caracteres de control. Cada byte cabe en 7 bits; el octavo es cero.
Todo encoding moderno es compatible hacia atrás con ASCII a nivel de bytes para esos 128 code points. Un archivo que solo contiene ASCII es simultáneamente ASCII válido, UTF-8, Latin-1 y Windows-1252. Por eso herramientas de los 80 siguen funcionando para texto solo en inglés.
UTF-8 — el default universal
UTF-8 es un encoding de longitud variable. Cada code point encodea a 1-4 bytes:
| Rango de code point | Bytes | Patrón binario |
|---|---|---|
| U+0000 a U+007F | 1 | 0xxxxxxx |
| U+0080 a U+07FF | 2 | 110xxxxx 10xxxxxx |
| U+0800 a U+FFFF | 3 | 1110xxxx 10xxxxxx 10xxxxxx |
| U+10000 a U+10FFFF | 4 | 11110xxx 10xxxxxx 10xxxxxx 10xxxxxx |
Tres consecuencias a interiorizar:
- Los code points ASCII son un byte cada uno, bit a bit idénticos a ASCII.
Aen UTF-8 es 0x41, el mismo byte que en ASCII. - Cualquier byte que empiece en
10es byte de continuación, nunca inicio de carácter. Por eso UTF-8 es self-synchronizing: puedes caer en mitad de un archivo y encontrar el siguiente inicio de carácter en máximo 3 bytes. - Los caracteres no-ASCII ocupan 2-4 bytes.
éson 2 bytes (0xC3 0xA9).你son 3. Un emoji de caca son 4.
UTF-8 es ya el encoding dominante en la web (más del 98% de páginas), en la mayoría de bases de datos, en sistemas de archivos Linux, y en el I/O por defecto de la mayoría de lenguajes.
# Ver los bytes detrás de un carácter
printf "é" | xxd
# 00000000: c3a9 ..
printf "A" | xxd
# 00000000: 41 A
printf "你" | xxd
# 00000000: e4bda0 ...
Para explorar interactivamente la relación entre caracteres y su representación binaria, las herramientas text-to-binary y binary-to-text te permiten ver el layout de bytes carácter a carácter.
UTF-16 — por qué persiste
UTF-16 encodea code points en 2 o 4 bytes. Era el encoding “obvio” cuando Unicode tenía 16 bits de ancho (pre-1996). Unicode creció más allá, así que UTF-16 añadió “pares surrogate” — dos unidades de 16 bits que juntas encodean un solo code point por encima de U+FFFF.
UTF-16 sigue apareciendo en tres sitios:
- APIs Windows internamente. Las versiones
Wde funciones Win32 (CreateFileW,MessageBoxW) toman wide strings UTF-16. - Strings Java y JavaScript internamente. El
string.lengthde JavaScript cuenta unidades de código UTF-16, no caracteres — por eso"💩".lengthes 2, no 1. - Algunos servicios XML y SOAP antiguos que emiten UTF-16 con BOM.
Para casi cualquier I/O nuevo, UTF-8 es lo correcto. UTF-16 no tiene ventaja de almacenamiento para texto en inglés (lo duplica), no es self-synchronizing, y tiene un problema de orden de bytes que UTF-8 no tiene.
// Indexación de strings en JavaScript es UTF-16, no code points
const s = "💩abc";
s.length; // 4 (2 code units para 💩 + 3)
[...s].length; // 4? No — 4 (el spread itera code points)
s.charAt(0); // "\uD83D" — surrogate solo, no un carácter
[...s][0]; // "💩" — iterando con spread se usan code points
Byte Order Mark — un peligro histórico
Un Byte Order Mark (BOM) es un U+FEFF opcional al inicio de un archivo que indica el orden de bytes de UTF-16 o UTF-32. En UTF-16LE es FF FE; en UTF-16BE es FE FF.
Para UTF-8 no hay ambigüedad de orden — bytes son bytes — pero Microsoft Notepad y otras tools escriben un BOM UTF-8 de tres bytes (EF BB BF) al inicio. Es legal por la spec Unicode pero desaconsejado. Rompe:
- Scripts de shell (el BOM aparece antes del
#!/bin/bash). - Parsers CSV que esperan que el primer campo empiece en el byte 0.
- Parsers YAML y JSON en algunas librerías antiguas.
git diffde un archivo que de repente tiene BOM.
Si tu export CSV desde Excel abre con caracteres raros antes del primer nombre de campo, tiene un BOM UTF-8. Quítalo con sed -i '1s/^\xEF\xBB\xBF//' file.csv o lee el archivo como utf-8-sig en Python.
Encodings legacy que siguen en circulación
| Encoding | Qué es | Dónde lo encuentras |
|---|---|---|
| Latin-1 / ISO-8859-1 | Caracteres europeos 1 byte | Default HTTP antiguo, dumps de BD antiguos |
| Windows-1252 | Latin-1 + algo de puntuación | Excel, programas Windows viejos |
| Shift-JIS | Japonés 1-2 bytes | Sistemas japoneses legacy |
| GB18030 | Chino (continental) | Sistemas gubernamentales chinos |
| UTF-16LE | Interno JavaScript / Windows | Serializado como formato ocasionalmente |
El carácter é es U+00E9 en Unicode, byte 0xE9 en Latin-1, bytes 0xC3 0xA9 en UTF-8. Cuando ves é en un navegador, estás viendo bytes UTF-8 siendo decodeados como Latin-1: los dos bytes UTF-8 de é se interpretan como dos caracteres Latin-1 separados. Este síntoma único te dice: “algo en la pipeline no es UTF-8-aware”.
Grapheme clusters — lo que va más allá de code points
Un carácter percibido por el usuario no siempre es un code point. 👨👩👧 es un emoji familia hecho de cinco code points unidos por zero-width joiners. é puede ser o U+00E9 (un code point) o U+0065 + U+0301 (dos code points: e + acute combinante). Para contar “caracteres” como los ve un usuario necesitas segmentación de grapheme clusters, que es el trabajo de Intl.Segmenter en JavaScript o el crate grapheme en Rust.
Por eso "💩".length === 2 no es la respuesta correcta a “cuántos caracteres tiene este string” — y por eso el word counter usa conteo grapheme-aware para resultados correctos en input con muchos emojis.
Reglas prácticas
- Usa UTF-8 en todos lados. Input, output, nombres de archivo, encoding de columna BD, headers HTTP. Todos.
- Declara el encoding en todo formato que lo soporte.
<meta charset="utf-8">en HTML,# -*- coding: utf-8 -*-en Python viejo,Content-Type: application/json; charset=utf-8en respuestas HTTP. - Strip BOMs en ingesta. Si aceptas archivos, maneja un BOM opcional. No emitas BOMs en salida.
- Nunca confíes en que “un string tiene una longitud” — sabe si cuentas bytes, unidades de código (UTF-16), code points, o grapheme clusters. Son cuatro números distintos.
Conclusiones
Unicode es el set de caracteres. UTF-8 es el encoding. Default a UTF-8 para todo. Los BOMs son peligro histórico; manéjalos en input, no los emitas en output. La longitud de un string son cuatro números distintos según qué quieras. Para el encoding de transporte vecino, mira la guía de Base64 en producción; para cómo el encoding de caracteres se cruza con URLs, la guía de URL encoding.