UTF-8 vs UTF-16 vs Unicode — Guía completa de codificación
La respuesta corta que casi todas las búsquedas sobre codificación utf-8 unicode están pidiendo: Unicode y UTF-8 no son lo mismo. Unicode es una enorme tabla numerada que asigna un code point (un número como U+1F600) a cada carácter. UTF-8, UTF-16 y UTF-32 son codificaciones — tres formas distintas de convertir esos code points en bytes. UTF-8 es la que casi siempre vas a querer: idéntica byte a byte a ASCII para texto en inglés, escala a cuatro bytes por cada emoji, y es obligatoria en JSON, HTML5 y la mayoría de los protocolos modernos.
Esta guía es para el desarrollador que ya se quemó: el error Incorrect string value de MySQL al guardar un 😀, la sorpresa de JavaScript con "😀".length === 2, el CSV que abre bien en cat pero sale ilegible en Excel. Recorreremos desde los code points hasta la mecánica de bytes de UTF-8, los pares sustitutos, los BOMs, el comportamiento por defecto de nueve lenguajes y ocho trampas de producción — y cerraremos con una matriz de decisión y FAQ.
¿Quieres verificar una secuencia de bytes mientras lees? Pega cualquier cadena en el Codificador y Decodificador Base64 — la carga útil decodificada es exactamente el flujo de bytes UTF-8 que este artículo explica.
Por qué la codificación sigue mordiéndote en 2026
Tres escenarios, todos sacados de bug trackers reales de los últimos doce meses:
- MySQL rechaza un emoji. Un usuario envía
Hello 😀y el servidor respondeIncorrect string value: '\xF0\x9F\x98\x80'. La tabla esutf8, el desarrollador piensa “eso es UTF-8, ¿qué pasa?” — y la respuesta está enterrada en la historia de MySQL (cubierto en la sección 7). - Un contador de caracteres se publica roto. Un validador de tweets de 280 caracteres usa
text.length, acepta un mensaje lleno de emojis, y la API lo rechaza. También ocurre lo contrario: un post válido es rechazado por el front end. Síntoma diagnosticado en la sección 4. - Un HTML local se convierte en “䏿–‡”. Un desarrollador guarda un archivo en Windows-1252, lo abre en un navegador que adivina UTF-8 y ve florecer el Mojibake. Esta es la historia del BOM y la declaración de charset en la sección 5, con paralelos en la Guía de Codificación y Decodificación de URL donde el mismo desajuste byte-vs-carácter destroza las query strings.
La promesa de esta guía: para cuando llegues a la última página vas a (a) distinguir Unicode de UTF-8 en una sola frase, (b) elegir entre UTF-8, UTF-16 y UTF-32 para cualquier proyecto nuevo, (c) escribir código que cuente correctamente los emojis en todos los lenguajes mayores y (d) depurar cualquier bug de charset solo con el flujo de bytes. La madriguera de la codificación de caracteres es profunda, pero la superficie práctica es pequeña.
¿Qué es Unicode? Code points vs caracteres vs glifos
Unicode es una tabla de caracteres que asigna un número único — un code point, como U+1F600 — a cada carácter. UTF-8, UTF-16 y UTF-32 son codificaciones que traducen esos code points a bytes. Unicode en sí mismo no almacena bytes; solo define el mapeo desde el carácter abstracto al entero.
Otros tres términos enturbian la conversación porque suelen referirse a la misma marca visible:
Tres capas que debes separar
- Code point (
U+0041,U+1F600): el entero que Unicode asigna. El espacio va desdeU+0000hastaU+10FFFF, aproximadamente 1,1 millones de slots, de los cuales unos 150.000 están asignados actualmente. - Carácter (o carácter abstracto): la identidad semántica — letra mayúscula latina A, emoji de cara sonriente.
- Glifo: la forma visual que renderiza una fuente. Un carácter tiene muchos glifos: una A con serifa, una A itálica, una A dibujada a mano. A Unicode no le importan los glifos.
- Grupo grafemático (grapheme cluster): lo que un usuario percibe como un solo “carácter”. A menudo es un code point, a veces varios. La letra á puede ser un code point
U+00E1o dos code pointsa + U+0301(acento agudo combinante) — la guía de límites de caracteres por plataforma explora cómo Twitter, SMS y SEO trazan cada uno esta línea de forma distinta.
Si no recuerdas nada más, recuerda esto: code point → codificación → bytes → renderizado. Cada flecha puede romperse de forma independiente.
Notación de code points — U+XXXX y \uXXXX
Verás los code points escritos de varias formas. U+0041 es la notación canónica de Unicode: de cuatro a seis dígitos hexadecimales, con el prefijo U+. En el código fuente:
- JavaScript / JSON:
"A"(cuatro dígitos hex, solo BMP) y"\u{1F600}"(llaves de ES6, cualquier code point). - Python:
"A"(4 dígitos),"\U00000041"(8 dígitos, U mayúscula),"\N{LATIN CAPITAL LETTER A}"(por nombre). - Shell / git log / salida de sed: a menudo verás bytes UTF-8 crudos como
\xc3\xa9paraé— eso no es un code point, es la forma codificada, lo que nos lleva a la sección 3.
Los 17 planos — BMP y más allá
Unicode divide su espacio de code points en 17 planos de 65.536 code points cada uno — 17 × 2^16 = 1.114.112.
- Plano 0, el Plano Multilingüe Básico (BMP, Basic Multilingual Plane):
U+0000aU+FFFF. Latín, ideogramas CJK, cirílico, árabe, griego — casi todos los sistemas de escritura que encuentras en texto antiguo viven aquí. - Planos 1-16, los planos suplementarios:
U+10000aU+10FFFF. La mayoría de los emojis (U+1F600y compañía), caracteres CJK raros, sistemas de escritura históricos (jeroglíficos egipcios, cuneiforme), notación musical.
La frontera BMP / suplementario en U+FFFF es el número más importante de este artículo. Ahí UTF-16 deja de ser una unidad de código por carácter, ahí UTF-8 salta de tres bytes a cuatro, y ahí se rinde la collation utf8 mal nombrada de MySQL.
Verificación rápida con emojis
"a" → 1 codepoint U+0061 → 1 grapheme
"é" (NFC) → 1 codepoint U+00E9 → 1 grapheme
"é" (NFD) → 2 codepoints U+0065 U+0301 → 1 grapheme
"😀" → 1 codepoint U+1F600 (Plane 1) → 1 grapheme
"👨👩👧" → 5 codepoints (3 people + 2 ZWJ U+200D) → 1 grapheme
La última fila es la sorpresa. El emoji de familia es un carácter percibido por el usuario, pero son cinco code points unidos por Zero-Width Joiners. Cada capa del stack puede contarlo de forma distinta, y la trampa 6 de la sección 7 es el bug report que presenta este desacuerdo.
Mecánica de la codificación UTF-8 — Cómo funcionan los 1 a 4 bytes
UTF-8 codifica los code points de Unicode en 1 a 4 bytes. ASCII (U+0000–U+007F) usa 1 byte y es idéntico byte a byte a ASCII. Los code points más altos usan secuencias multibyte donde el primer byte indica la longitud total y cada byte de continuación empieza con el patrón de bits 10xxxxxx. Esta estructura autodescriptiva es la razón por la que UTF-8 ganó las guerras de codificación.
La tabla de patrones de bytes — UTF-8 en un solo diagrama
| Rango de code points | Bytes UTF-8 | Patrón de bytes |
|---|---|---|
U+0000 – U+007F | 1 byte | 0xxxxxxx |
U+0080 – U+07FF | 2 bytes | 110xxxxx 10xxxxxx |
U+0800 – U+FFFF | 3 bytes | 1110xxxx 10xxxxxx 10xxxxxx |
U+10000 – U+10FFFF | 4 bytes | 11110xxx 10xxxxxx 10xxxxxx 10xxxxxx |
Cada x es un bit de datos extraído de la representación binaria del code point. El 0 / 110 / 1110 / 11110 inicial le dice al decodificador cuántos bytes hay en total; el 10 inicial marca cada byte de continuación. Esa redundancia hace que UTF-8 sea autosincronizante — pierdes un byte y puedes retomar en el siguiente byte de inicio en lugar de corromper todo lo que viene después.
Ejemplo paso a paso — codificando 中 (U+4E2D)
El code point 0x4E2D cae dentro de U+0800–U+FFFF, así que usamos la plantilla de 3 bytes.
- Binario:
0x4E2D=0100 1110 0010 1101(16 bits). - Dividir 4-6-6 para encajar en las ranuras
x:0100 / 111000 / 101101. - Sustituir en
1110xxxx 10xxxxxx 10xxxxxx:11100100 10111000 10101101. - Hex:
0xE4 0xB8 0xAD.
Por eso 中 se convierte en %E4%B8%AD tras la codificación URL: el percent-encoding envuelve cada byte UTF-8 en %XX, no codifica el code point directamente. La trampa 3 de la sección 7 detalla la cadena.
Ejemplo paso a paso — codificando 😀 (U+1F600)
El code point 0x1F600 excede el BMP, así que usamos la plantilla de 4 bytes.
- Binario:
0x1F600=0 0001 1111 0110 0000 0000(21 bits, con padding). - Dividir 3-6-6-6:
000 / 011111 / 011000 / 000000. - Sustituir en
11110xxx 10xxxxxx 10xxxxxx 10xxxxxx:11110000 10011111 10011000 10000000. - Hex:
0xF0 0x9F 0x98 0x80.
Esos cuatro bytes son con los que se atraganta la collation utf8 de MySQL — asigna como máximo tres bytes por carácter. La trampa 1 de la sección 7 tiene la solución.
Por qué ganó UTF-8 — tres superpoderes
- Compatibilidad con ASCII. Un archivo de texto ASCII puro es idéntico a nivel de bytes a su codificación UTF-8. Décadas de herramientas anteriores a Unicode —
grep,awk, las clásicas tuberías de shell — siguen funcionando para ese subconjunto. - Autosincronizante. Los bytes de continuación siempre empiezan con
10, que nunca colisiona con ningún byte de inicio. Pierde un byte en una transferencia de red y resincronizas en el siguiente límite de carácter en lugar de propagar basura en cascada. - Sin orden de bytes. UTF-8 es un flujo de bytes, no de unidades de 16 o 32 bits, así que el endianness es irrelevante. UTF-16 y UTF-32 necesitan un Byte Order Mark para declarar qué extremo va primero; UTF-8 no lo necesita (y generalmente no debería tenerlo — ver sección 5).
UTF-8 inválido — lo que la especificación prohíbe
Un decodificador estricto rechazará estas secuencias de bytes:
- Secuencias de 5 o 6 bytes. Los RFCs antiguos las permitían; el RFC 3629 (2003) limitó UTF-8 a 4 bytes para coincidir con el espacio Unicode de 21 bits.
- Codificaciones sobredimensionadas (overlong). Codificar
/como tres bytes0xE0 0x80 0xAFen lugar de un solo byte0x2F. En su momento fue una fuente fértil de exploits de directory-traversal en validadores de rutas que decodificaban tras sanitizar. - Code points de sustituto solitarios (
U+D800–U+DFFF). Están reservados para UTF-16 y nunca deberían aparecer en UTF-8. - Secuencias truncadas. Un byte de inicio de 3 bytes seguido de un solo byte de continuación — común cuando la entrada del usuario se corta en un límite de byte en medio de un carácter multibyte.
Para ver cualquiera de esto de forma concreta, lanza una cadena al Codificador y Decodificador Base64, codifícala y luego decodifícala de vuelta como bytes — el array de bytes entre codificador y decodificador es el flujo UTF-8 que describe esta sección.
UTF-16 y pares sustitutos — Por qué length miente en JavaScript
La búsqueda más común alrededor de utf-8 vs utf-16 en realidad es “¿por qué "😀".length da 2 en mi código?” La respuesta son los pares sustitutos, una decisión de los años 90 que JavaScript, Java, C# y Windows heredaron todos.
UTF-16 en un solo párrafo
UTF-16 representa Unicode usando unidades de código de 16 bits. Los caracteres del BMP (U+0000–U+FFFF) ocupan exactamente una unidad de código. Los caracteres de los planos suplementarios (U+10000–U+10FFFF) ocupan dos unidades de código, llamadas un par sustituto (surrogate pair): un sustituto alto en U+D800–U+DBFF seguido de un sustituto bajo en U+DC00–U+DFFF. Ese bloque U+D800–U+DFFF está reservado permanentemente en Unicode, así que ningún carácter real vive ahí. UTF-16 es el formato de cadena interno de JavaScript, Java, C# (.NET), las APIs del kernel de Windows, NSString de Objective-C y Qt — todos diseñados cuando 65.536 caracteres parecían más que suficientes.
La trampa de String.length
"a".length // 1 — BMP, single code unit
"é".length // 1 — BMP (U+00E9), single code unit
"中".length // 1 — BMP (U+4E2D), single code unit
"😀".length // 2 — supplementary plane (U+1F600), surrogate pair!
"a😀".length // 3 — one BMP + two surrogate units
String.prototype.length reporta el número de unidades de código UTF-16, no el número de caracteres. Cualquier cosa del plano suplementario cuenta como 2. La misma trampa existe en String.length() de Java y string.Length de C#.
Contar code points correctamente en JS
[..."😀"].length // 1 — spread iterator walks codepoints
Array.from("😀").length // 1 — Array.from also walks codepoints
"😀".match(/./gu).length // 1 — /u flag = unicode-aware regex
// "😀".charAt(0) returns the lone high surrogate (visually broken)
"😀".codePointAt(0) // 128512 — the full codepoint U+1F600
El operador spread y Array.from usan el protocolo iterador, que la especificación del lenguaje define como recorrer code points. El acceso por índice plano (str[0], charAt) sigue devolviendo unidades de código y te entregará la mitad de un par sustituto en los emojis.
Python — len() ya hace lo correcto (casi)
len("😀") # 1 — Python 3 strings are codepoint-indexed
len("👨👩👧") # 5 — codepoints (3 humans + 2 ZWJ), not graphemes
# Python 2 was byte-indexed by default — len("😀") returned 4
Python 3 guarda las cadenas en una representación flexible de 1, 2 o 4 bytes (PEP 393) y las indexa por code point. len("😀") es 1, pero sigue sin ser el conteo de grafemas — el emoji de familia sigue contando como 5. Para contar caracteres percibidos por el usuario necesitas una librería de grafemas: Intl.Segmenter en JavaScript (Node 22+, todos los navegadores actuales), grapheme o regex en Python, o simplemente Swift, cuyo String.count es el único lenguaje mainstream que cuenta grafemas por defecto.
UTF-16 vs UCS-2 — la migración silenciosa
Antes de 1996, Unicode prometió caber en 16 bits y la codificación correspondiente era UCS-2 — un mapeo fijo de 2 bytes. Unicode 2.0 rompió esa promesa al añadir los planos suplementarios. UTF-16 es la versión parcheada que usa pares sustitutos. La especificación de JavaScript todavía cita el vocabulario antiguo de UCS-2 en algunas partes, y por eso el lenguaje tolera sustitutos solitarios que deberían ser ilegales — los chistes sobre “WTF-16” son reales. Las APIs de la plataforma web (DOM, fetch, TextEncoder) rechazan los sustitutos solitarios porque no se pueden codificar como UTF-8 válido.
UTF-32, BOM y la cuestión del orden de bytes
UTF-32 — la simple y derrochadora
UTF-32 usa 4 bytes fijos por code point. U+0041 se almacena como 0x00000041, U+1F600 como 0x0001F600. La ventaja es el acceso aleatorio en tiempo constante: el code point n-ésimo está en el offset de byte 4n. La desventaja es el tamaño — un texto ASCII puro se hincha a cuatro veces su huella en UTF-8, e incluso el texto CJK se duplica. Casi ningún sistema almacena UTF-32 en disco. Internamente, Python 3 elige 1, 2 o 4 bytes por cadena según el code point más alto; el stack fontconfig de Linux usa UTF-32 para sus tablas de glifos en memoria.
Orden de bytes — por qué el endianness importa en UTF-16 / UTF-32
UTF-8 es un flujo de bytes individuales, así que el endianness no aplica. UTF-16 y UTF-32 operan sobre unidades multibyte, y CPUs distintas no se ponen de acuerdo sobre qué extremo de un número va primero.
U+0041 ('A') in UTF-16 BE → 00 41
U+0041 ('A') in UTF-16 LE → 41 00
Las CPUs x86 y ARM son little-endian; los PowerPC antiguos y el “network byte order” son big-endian. Cuando escribes un archivo UTF-16 tienes que comprometerte con uno y avisarle al lector cuál, y para eso sirve el BOM.
El BOM — qué es, cuándo usarlo
Un Byte Order Mark es U+FEFF colocado al inicio de un archivo. Codificado, anuncia tanto la codificación como (para UTF-16 / UTF-32) el orden de bytes.
| Codificación | Bytes del BOM |
|---|---|
| UTF-8 | EF BB BF |
| UTF-16 BE | FE FF |
| UTF-16 LE | FF FE |
| UTF-32 BE | 00 00 FE FF |
| UTF-32 LE | FF FE 00 00 |
El BOM de UTF-8 existe, pero no lleva información de orden de bytes porque UTF-8 no tiene orden de bytes. Su único trabajo es declarar “este archivo es UTF-8” — útil para herramientas que no tienen otra señal, dañino para herramientas que esperan que el archivo empiece con un número mágico o una directiva.
Matriz de decisión para el BOM — ¿debería añadirlo?
| Formato | BOM UTF-8 | BOM UTF-16 | BOM UTF-32 |
|---|---|---|---|
| HTML | No (rompe la detección de <!doctype> en parsers antiguos) | — | — |
| JSON | No (el RFC 8259 lo prohíbe) | — | — |
| Código fuente JavaScript / CSS | Evitar (versiones antiguas de Node e IE se atragantan) | — | — |
| CSV abierto en Excel | Sí (Excel lee UTF-8 sin BOM como ANSI y destroza el CJK) | — | — |
| XML | Opcional (la declaración XML ya indica la codificación) | Requerido | Requerido |
Texto plano .txt | Opcional (el Bloc de notas de Windows añade uno por defecto) | Requerido | Requerido |
La regla corta: quita el BOM de UTF-8 de cualquier cosa servida en la web; añádelo a los CSV que quieras que Excel abra; deja que el lector decida para todo lo demás.
9 lenguajes lado a lado — Comportamiento de codificación por defecto
Trabajar entre lenguajes es donde este conocimiento se paga solo. La misma cadena "a😀é" produce una longitud diferente en cada runtime que invocas desde tu script de Bash.
La tabla de comportamiento entre lenguajes
| Lenguaje | Codificación del archivo fuente | Almacenamiento de cadenas | Qué cuenta length / len | Codificación de I/O por defecto | ¿Seguro con emojis de 4 bytes? |
|---|---|---|---|---|---|
| JavaScript (V8 / SpiderMonkey) | UTF-8 | UTF-16 | unidades de código UTF-16 | UTF-8 (Node, Web) | Sí, pero .length === 2 |
| Python 3 | UTF-8 (PEP 3120) | dinámico 1 / 2 / 4 bytes (PEP 393) | code points | UTF-8 (PEP 540 desde 3.7) | Sí, len === 1 |
| Java | UTF-8 (default de javac) | UTF-16 | unidades de código UTF-16 | charset de la plataforma → UTF-8 (JEP 400, JDK 18+) | Sí, pero .length() === 2 |
| Go | UTF-8 | bytes UTF-8 | bytes (utf8.RuneCountInString para code points) | UTF-8 | Sí, len(s) devuelve bytes |
| Rust | UTF-8 | bytes UTF-8 (invariante de String) | .len() bytes, .chars().count() code points | UTF-8 | Sí, explícito |
| C# (.NET) | UTF-8 (default desde .NET Core 3.0) | UTF-16 | unidades de código UTF-16 | UTF-8 (Encoding.Default desde .NET 5) | Sí, pero .Length === 2 |
| Ruby | UTF-8 (desde 2.0) | etiqueta de codificación por cadena | code points (.length) | UTF-8 | Sí, length === 1 |
| PHP | (sin codificación de fuente) | cadena de bytes | bytes (strlen); mb_strlen para code points | depende de default_charset | Sí, con la familia mb_* |
| MySQL | — | charset de columna | bytes (LENGTH), caracteres (CHAR_LENGTH) | variables de sistema character_set_* | Solo con utf8mb4 |
Lo que la tabla realmente te dice
Tres filosofías, tres conjuntos de bugs:
- UTF-8 interno (Go, Rust, Ruby). La cadena nativa son bytes;
lengthestá bien definido pero cuenta lo que cuenta. Convierte a code points o grafemas solo cuando cruzas un límite de UI o validación. - UTF-16 interno (JavaScript, Java, C#). Heredado de los supuestos de los años 90;
lengthson unidades de código, un par sustituto cuenta como 2. Usa iteración consciente de code points para cualquier conteo visible al usuario. - Indexado por code points (Python 3).
lenda code points, lo que se siente correcto hasta que te topas con los emojis con ZWJ — entonces sigues necesitando una librería de grafemas.
PHP es el caso especial. Sus funciones str* integradas operan todas sobre bytes, tratando las secuencias UTF-8 como blobs opacos. Cualquier proyecto que no sea solo ASCII debe usar la familia mb_* (multibyte), y los bug reports año tras año muestran lo a menudo que se pasa por alto.
La guía práctica: mantén UTF-8 como formato de transmisión en todas partes — archivos, cuerpos HTTP, columnas de base de datos — y convierte al tipo de cadena nativo de tu runtime en el límite. Este es el “sándwich UTF-8” al que volvemos en la sección 8.
8 trampas de ingeniería del mundo real
Los patrones de abajo aparecen en cada code review de un codebase globalizado.
Trampa 1: el utf8 de MySQL es una mentira de 3 bytes — cámbiate a utf8mb4
Síntoma. INSERT INTO users (bio) VALUES ('Hello 😀'); devuelve Incorrect string value: '\xF0\x9F\x98\x80' for column 'bio'.
Causa raíz. El utf8 histórico de MySQL es un alias de utf8mb3: una variante de UTF-8 limitada a tres bytes por carácter. Cualquier code point por encima de U+FFFF (todos los emojis, varios miles de caracteres CJK raros, todos los sistemas de escritura históricos) requiere cuatro bytes UTF-8 y es rechazado.
Solución.
ALTER DATABASE mydb CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
ALTER TABLE users CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
SET NAMES utf8mb4; -- client connection
# my.cnf
[mysqld]
character-set-server = utf8mb4
collation-server = utf8mb4_unicode_ci
MySQL 8.0 sigue trayendo utf8 como alias de utf8mb3. utf8mb3 está deprecated pero todavía no se ha eliminado. Usa utf8mb4 para cada columna nueva, cada base de datos nueva, cada conexión nueva — la variante legacy no aporta ningún beneficio.
Trampa 2: el fallback a Windows-1252 — el misterio del signo de interrogación
Síntoma. Un .txt exportado desde el Bloc de notas de un colega en Windows muestra "comillas tipográficas" y un guion largo en su máquina. En tu servidor se convierte en ? o U+FFFD (carácter de reemplazo).
Causa raíz. El Bloc de notas antiguo usa por defecto Windows-1252 (CP-1252), que codifica la comilla curva " como 0x93. Un decodificador UTF-8 ve 0x93 como un byte de continuación huérfano (bit alto 10) sin un byte de inicio que lo preceda, y sustituye por el carácter de reemplazo.
Solución. Detecta la codificación de origen (file en Unix, chardet / charset-normalizer en Python, jschardet en Node), decodifica con el códec correcto y luego vuelve a codificar como UTF-8 antes de guardar. Estandarizar en UTF-8 en la ingesta elimina la recurrencia.
Trampa 3: el percent-encoding de URL ≠ UTF-8 (pero se construye sobre él)
Síntoma. fetch("/search?q=中文") devuelve 404 desde un framework backend y funciona desde otro.
Causa raíz. El percent-encoding opera sobre bytes, no sobre code points. 中 es un code point pero son tres bytes UTF-8 (E4 B8 AD), cada uno codificado por separado como %E4%B8%AD — nueve caracteres ASCII en la URL. Un framework que decodifica la URL como Latin-1 en lugar de UTF-8 entregará al handler los tres bytes destrozados interpretados como tres caracteres de un solo byte.
Solución. Usa encodeURIComponent("中文") en el cliente (los navegadores hacen UTF-8 + percent-encode en un solo paso) y confirma que el framework del servidor decodifica las URLs como UTF-8 (todos los frameworks modernos lo hacen por defecto). Para confirmación visual, pega 中文 en el Decodificador y Codificador URL y míralo convertirse en %E4%B8%AD%E6%96%87. La cadena completa está cubierta en la Guía de Codificación y Decodificación de URL.
Trampa 4: la entrada de Base64 son bytes, pero tú escribiste una cadena
Síntoma. btoa("你好") lanza InvalidCharacterError: The string contains characters outside the Latin1 range.
Causa raíz. btoa fue diseñado en la era de ASCII / Latin-1. Espera que cada carácter de entrada quepa en un solo byte (code points 0-255). 你好 es UTF-16 en el motor de JS con code points U+4F60 U+597D, ambos muy por encima de 255.
Solución. Codifica a bytes UTF-8 primero, y luego haz Base64 sobre esos bytes.
// Wrong:
btoa("你好"); // throws
// Correct:
const bytes = new TextEncoder().encode("你好");
// Uint8Array(6) [228, 189, 160, 229, 165, 189]
const b64 = btoa(String.fromCharCode(...bytes));
// "5L2g5aW9"
La historia más larga está en ¿Qué es la Codificación Base64? Una Guía para Principiantes y la Guía Avanzada de Base64: MIME, Data URLs, Rendimiento y Seguridad; el Codificador y Decodificador Base64 hace la conversión en un solo paso y muestra el flujo de bytes intermedio.
Trampa 5: usar String.length para validar (límites de Twitter / SMS)
Síntoma. Un composer de 280 caracteres valida en el cliente, y luego la API devuelve 422. O al revés — un post perfectamente válido es rechazado por el cliente.
Causa raíz. El .length de JavaScript cuenta unidades de código UTF-16; un solo emoji cuenta como 2. Twitter cuenta code points (emoji = 1). El conteo de caracteres está mal en direcciones opuestas dependiendo de qué API confíes.
Solución. Usa [...text].length para el conteo de code points, o Intl.Segmenter para un conteo real de grafemas (el enfoque de Bluesky / iMessage). Los números plataforma por plataforma y los límites GSM-7 vs UCS-2 de SMS están catalogados en la guía de límites de caracteres por plataforma.
Trampa 6: las familias de emojis con ZWJ cuentan como N code points, 1 grafema
Síntoma. "👨👩👧".length === 8. Contar code points da 5. Para el usuario es una sola imagen.
Causa raíz. El Zero-Width Joiner (U+200D) pega varios code points de emoji en un único cluster renderizado — tres emojis de persona más dos ZWJ son cinco code points, ocho unidades de código UTF-16, un grafema.
Solución.
const seg = new Intl.Segmenter('en', { granularity: 'grapheme' });
[...seg.segment("👨👩👧")].length; // 1
Intl.Segmenter está en Node 22+ y todos los navegadores actuales. Para runtimes más antiguos, el paquete grapheme-splitter implementa UAX #29.
Trampa 7: el escape \uXXXX de JSON — los code points por encima de U+FFFF necesitan un par sustituto
Síntoma. Un payload JSON contiene "😀" y el decodificador receptor o bien lo renderiza correctamente como 😀 o muestra dos cajitas, dependiendo de si entiende pares sustitutos en JSON.
Causa raíz. El escape \uXXXX de JSON solo acepta exactamente cuatro dígitos hex — es decir, una unidad de código UTF-16. Codificar 😀 (U+1F600) requiere el par sustituto 😀. No existe sintaxis de llaves \u{...} en JSON.
Solución. O acepta el par sustituto (cualquier parser que cumpla la especificación lo maneja) o escribe el emoji literalmente — JSON permite cualquier carácter UTF-8 fuera de la sintaxis de escape, y la mayoría de los parsers modernos prefieren esa forma.
Trampa 8: los defaults de Content-Type: charset= en HTTP no son lo que crees
Síntoma. Una página HTML UTF-8 se renderiza como Mojibake en un navegador y correctamente en otro.
Causa raíz. El RFC 2616 originalmente obligaba a ISO-8859-1 como default para respuestas text/* sin charset explícito. El RFC 7231 (2014) quitó ese default, dejando a cada navegador adivinar. Algunos analizan el contenido, otros caen en UTF-8, otros usan por defecto el locale del sistema.
Solución. Envía siempre Content-Type: text/html; charset=utf-8 desde el servidor y <meta charset="utf-8"> en el head del documento. Cualquiera por separado funciona; ambos son cinturón y tirantes para proxies legacy que quitan headers.
Para ver cualquiera de estas trampas en vivo a nivel de bytes, el Codificador y Decodificador Base64 es el microscopio más rápido: pega una cadena, codifícala a Base64, y la carga útil decodificada es el flujo UTF-8.
Eligiendo la codificación correcta — Matriz de decisión
Para la pregunta utf-8 vs utf-16, la respuesta casi siempre es UTF-8. La tabla de abajo cubre los casos extremos.
Matriz de decisión
| Escenario | Elige | Por qué |
|---|---|---|
| Páginas web, JSON de APIs, archivos fuente | UTF-8 (sin BOM) | Compatible con ASCII, sin orden de bytes, el más pequeño para texto latino, el RFC 8259 obliga a UTF-8 en JSON |
| Almacenamiento CJK pesado (BD china, datos de juegos en japonés) | UTF-8 (utf8mb4) | UTF-8 usa 3 bytes por carácter CJK frente a los 2 de UTF-16, pero el overhead de ASCII del markup y las keys de JSON deja a UTF-8 por delante en la práctica — y el ecosistema circundante es UTF-8 |
| API nativa de Windows, código legacy de Java / C# | UTF-16 | Default de la plataforma; convertir en cada llamada a la API invita a bugs |
| Procesamiento de texto en memoria intensivo en índices | UTF-32 | Acceso a code points en tiempo constante; vale la pena solo para hot paths de parsers |
| CSV abierto en Excel en Windows | UTF-8 con BOM | Excel lee UTF-8 sin BOM como ANSI y destroza los encabezados CJK |
| Proyecto nuevo, sin restricciones | UTF-8 (sin BOM) | Las guerras de codificación terminaron de forma decisiva |
Dos reglas prácticas
- Default a UTF-8 en todas partes salvo que una plataforma te obligue a otra cosa. El W3C, la IETF y el Consorcio Unicode coinciden.
- Convierte en el límite, no en el medio. Decodifica bytes al tipo de cadena nativo de tu lenguaje al entrar. Opera sobre cadenas, nunca sobre bytes, en la lógica de negocio. Vuelve a codificar a UTF-8 en la salida. Este “sándwich UTF-8” elimina toda la clase de bugs de mojibake en medio del pipeline.
Preguntas frecuentes
¿UTF-8 siempre es compatible hacia atrás con ASCII?
Sí. Cualquier archivo ASCII válido es idéntico bit a bit a su representación UTF-8. Los primeros 128 code points (U+0000–U+007F) se codifican como un solo byte con el bit alto a cero. Las herramientas legacy de solo ASCII — los primeros grep, sed, las clásicas tuberías de shell — procesan archivos UTF-8 puramente ASCII sin modificaciones. Los problemas empiezan solo cuando bytes no ASCII (con el bit alto activado) entran en el flujo.
¿Debería usar BOM UTF-8 en mis archivos?
Por defecto, no. Los archivos HTML, JSON, JavaScript y CSS se rompen o producen warnings en algunos parsers cuando aparece un BOM al inicio. La excepción estándar es CSV destinado a Excel en Windows — sin el BOM, Excel adivina ANSI y destroza los encabezados en chino, japonés o coreano. Mira la matriz de decisión del BOM en la sección 5.
¿Por qué "😀".length === 2 en JavaScript?
Las cadenas de JavaScript se almacenan como UTF-16, y .length devuelve el número de unidades de código, no de caracteres. 😀 (U+1F600) vive en el plano suplementario y requiere un par sustituto — dos unidades de código de 16 bits — así que .length es 2. Usa [..."😀"].length, Array.from("😀").length, o Intl.Segmenter para un conteo real.
¿Cuál es la diferencia entre Unicode y UTF-8?
Unicode es la tabla de caracteres que asigna un code point (un número como U+1F600) a cada carácter. UTF-8 es una de varias codificaciones que traducen esos code points a bytes (de 1 a 4 bytes por code point). Unicode define qué es un carácter; UTF-8 define cómo viaja por un archivo o por la red. UTF-16 y UTF-32 son codificaciones alternativas de la misma tabla Unicode.
¿utf8mb4 siempre es más seguro que utf8 en MySQL?
Sí para proyectos nuevos. El utf8 de MySQL es la variante mal nombrada utf8mb3 limitada a 3 bytes, que no puede almacenar ningún carácter por encima de U+FFFF — todos los emojis, muchos caracteres CJK raros, todos los sistemas de escritura históricos. utf8mb4 es UTF-8 completo de 4 bytes. La única salvedad es la longitud del índice: cada carácter utf8mb4 puede ocupar 4 bytes, así que el límite legacy de índices InnoDB de 767 bytes restringe los índices únicos a 191 caracteres (resuelto por innodb_large_prefix en MySQL 5.7+ y por defecto en 8.0).
¿Cómo detecto la codificación de un archivo desconocido?
Usa file en Unix, chardet o charset-normalizer en Python, o jschardet en Node. Ninguno es perfecto — adivinan estadísticamente a partir de la distribución de bytes. La detección de UTF-8 es muy fiable gracias al patrón de los bytes de continuación. Windows-1252, ISO-8859-1 y otras codificaciones legacy de un solo byte son casi indistinguibles entre sí, así que la detección a menudo se reduce a heurísticas de lenguaje.
¿Puede UTF-16 representar todos los caracteres Unicode?
Sí. UTF-16 cubre los 1.114.112 code points. Los caracteres del BMP (U+0000–U+FFFF) usan una unidad de código de 16 bits (2 bytes), y los caracteres del plano suplementario (U+10000–U+10FFFF) usan pares sustitutos (4 bytes). La cobertura es idéntica a la de UTF-8 y UTF-32; solo difieren el layout de bytes y la semántica de procesamiento. La elección entre ellos es cuestión de encaje con el ecosistema, no de capacidad.