Caracteres, code points y bytes UTF-8 vistos con xxd

Por qué len('año') da 3 pero el archivo pesa 4 bytes: code points y bytes UTF-8 en xxd, len de Python frente a strlen de PHP y mediciones reales de tamaño.

Serie: curso de texto y codificación 1/6 (principiante). Para quien ya vio "año" en una página o "caracteres raros" al abrir un CSV, y quiere entender qué es realmente un carácter. Al terminar vas a poder leer bytes UTF-8 en un volcado hexadecimal, predecir cuántos bytes ocupa un texto y saber qué "longitud" devuelve cada función.

Este curso es para quien trabaja con texto de usuarios en español: nombres con ñ, direcciones con tildes, comentarios que empiezan con "¿" o "¡". Tres sorpresas se repiten: len("año") da 3 pero la base de datos dice 4 bytes, un sistema viejo en Windows-1252 guarda la ñ en un byte y el nuevo en dos, y strtoupper deja la ñ en minúscula. Las tres vienen de un solo hecho: un carácter, un code point y un byte son tres cosas distintas, y cada herramienta cuenta una diferente. Todas las salidas de abajo se obtuvieron ejecutando el código en una Mac con Apple Silicon (M1 Pro) con Python 3.9.6, PHP 8.5.6 y el xxd que trae macOS.

Carácter, code point y byte son tres cosas distintas

Antes de tocar código, pongámosle nombre a las tres capas.

  • Un carácter es lo que ve una persona: ñ, é, ¿, 👍. (En la lección 5 verás que hasta esto es difuso: 👨‍👩‍👧 parece un solo carácter pero son cinco code points.)
  • Un code point (punto de código) es el número que Unicode le asigna a un carácter. Se escribe U+ más hexadecimal: ñ es U+00F1, ¿ es U+00BF, € es U+20AC, 👍 es U+1F44D. Van de U+0000 a U+10FFFF.
  • Un byte es lo que realmente guarda un archivo, una conexión o una columna de base de datos. Una codificación es la regla que convierte code points en bytes. UTF-8 es una de esas reglas; Latin-1 (ISO-8859-1), Windows-1252 y UTF-16 son otras.

Un str de Python es una secuencia de code points; en el disco o en la red hay bytes. Pasar de str a bytes es codificar (encode) y de bytes a str es decodificar (decode). Casi todos los errores de codificación, incluidos los "ñ" de la lección 2, son un decode hecho con la regla equivocada.

Diagrama. Fila superior: el texto año tal como lo ve una persona, sus code points U+0061 U+00F1 U+006F y sus bytes UTF-8 61 C3 B1 6F, con una flecha de codificar de code points a bytes. Fila inferior: los cuatro tamaños de UTF-8: 1 byte hasta U+007F, como A; 2 bytes hasta U+07FF, como ñ, ¿ y ¡; 3 bytes hasta U+FFFF, como €; y 4 bytes hasta U+10FFFF, como el emoji del pulgar arriba.
Tres capas del mismo texto. Codificar convierte code points en bytes; decodificar convierte los bytes de vuelta. Cuántos bytes ocupa depende del rango del code point.

Tus primeros cinco minutos: mira los bytes con xxd

xxd muestra los bytes crudos de lo que le pases por una tubería. printf '%s' evita el salto de línea que agregaría echo:

for s in A ñ ¿ € 👍; do printf '%s' "$s" | xxd; done
00000000: 41                                       A
00000000: c3b1                                     ..
00000000: c2bf                                     ..
00000000: e282 ac                                  ...
00000000: f09f 918d                                ....

A es un byte, 0x41, el mismo valor que tiene en ASCII desde los años sesenta. ñ y ¿ ocupan dos bytes cada uno, € tres y el emoji cuatro. Los puntos de la derecha son xxd diciendo que esos bytes no son ASCII imprimible.

Lo mismo desde Python, que puede mostrar code points y bytes lado a lado:

import unicodedata
for s in ["hola", "año", "¿Qué?", "¡Olé!", "canción", "Ñandú", "€20", "👍"]:
    b = s.encode("utf-8")
    print(f"{s!r:10} len={len(s):2}  bytes={len(b):2}  {b.hex(' ')}")
for ch in "Añ¿¡é€👍":
    print(f"{ch} U+{ord(ch):04X} {unicodedata.name(ch)} -> {ch.encode().hex(' ')}")
'hola'     len= 4  bytes= 4  68 6f 6c 61
'año'      len= 3  bytes= 4  61 c3 b1 6f
'¿Qué?'    len= 5  bytes= 7  c2 bf 51 75 c3 a9 3f
'¡Olé!'    len= 5  bytes= 7  c2 a1 4f 6c c3 a9 21
'canción'  len= 7  bytes= 8  63 61 6e 63 69 c3 b3 6e
'Ñandú'    len= 5  bytes= 7  c3 91 61 6e 64 c3 ba
'€20'      len= 3  bytes= 5  e2 82 ac 32 30
'👍'        len= 1  bytes= 4  f0 9f 91 8d
A U+0041 LATIN CAPITAL LETTER A -> 41
ñ U+00F1 LATIN SMALL LETTER N WITH TILDE -> c3 b1
¿ U+00BF INVERTED QUESTION MARK -> c2 bf
¡ U+00A1 INVERTED EXCLAMATION MARK -> c2 a1
é U+00E9 LATIN SMALL LETTER E WITH ACUTE -> c3 a9
€ U+20AC EURO SIGN -> e2 82 ac
👍 U+1F44D THUMBS UP SIGN -> f0 9f 91 8d

Fíjate en dos patrones. Todas las letras del español fuera de ASCII (á é í ó ú ü ñ, sus mayúsculas, ¿ y ¡) están entre U+0080 y U+00FF y ocupan dos bytes que empiezan con c2 o c3. Ese c3 es justo el byte que se convierte en la famosa à cuando alguien lee UTF-8 como Windows-1252; lo verás en la lección 2.

Cómo reparte UTF-8 los bits, de 1 a 4 bytes

UTF-8 usa los bits altos del primer byte para decir cuánto mide la secuencia y marca cada byte siguiente con 10:

Rango de code points Bytes Patrón de bits Ejemplos
U+0000–U+007F 1 0xxxxxxx A 41, ? 3F
U+0080–U+07FF 2 110xxxxx 10xxxxxx ñ C3 B1, ¿ C2 BF, ú C3 BA
U+0800–U+FFFF 3 1110xxxx 10xxxxxx 10xxxxxx € E2 82 AC, “ E2 80 9C
U+10000–U+10FFFF 4 11110xxx 10xxxxxx 10xxxxxx 10xxxxxx 👍 F0 9F 91 8D

Desarmemos la ñ. Su code point U+00F1 es 11110001 en binario, ocho bits, demasiados para los siete de un byte. Entonces va al patrón de 2 bytes, rellenado a 11 bits como 00011 110001:

ñ U+00F1 = 11110001   utf-8: 11000011 10110001   (C3 B1)

Eso es todo el formato. Para comprobarlo, aquí tienes un decodificador de 10 líneas que solo usa la tabla:

import sys

def decode_one(b):
    x = b[0]
    if x < 0x80: return x, 1
    if x >> 5 == 0b110:  return ((x & 0x1F) << 6) | (b[1] & 0x3F), 2
    if x >> 4 == 0b1110: return ((x & 0x0F) << 12) | ((b[1] & 0x3F) << 6) | (b[2] & 0x3F), 3
    if x >> 3 == 0b11110:
        return ((x & 0x07) << 18) | ((b[1] & 0x3F) << 12) | ((b[2] & 0x3F) << 6) | (b[3] & 0x3F), 4
    raise ValueError(f"not a lead byte: {x:#04x}")

data, i, out = sys.argv[1].encode(), 0, []
while i < len(data):
    cp, n = decode_one(data[i:]); out.append(f"U+{cp:04X}"); i += n
print(out)
$ python3 dec.py "año ¿¡"
['U+0061', 'U+00F1', 'U+006F', 'U+0020', 'U+00BF', 'U+00A1']

Gracias a la marca 10xxxxxx, desde cualquier byte puedes encontrar el inicio de su carácter retrocediendo sobre los bytes de continuación. Eso se llama autosincronización: un byte dañado arruina un carácter, no el resto del archivo.

¿Qué "longitud"? len() de Python frente a strlen y mb_strlen de PHP

En Python 3, len() sobre un str cuenta code points; sobre bytes, cuenta bytes. Los strings de PHP son bytes, así que strlen cuenta bytes y las funciones mb_ cuentan code points:

<?php
foreach (["año", "¿Qué?", "canción", "Ñandú"] as $s) {
    printf("%s strlen=%d mb_strlen=%d\n", $s, strlen($s), mb_strlen($s));
}
echo strtoupper("año ñandú"), " | ", mb_strtoupper("año ñandú"), "\n";
año strlen=4 mb_strlen=3
¿Qué? strlen=7 mb_strlen=5
canción strlen=8 mb_strlen=7
Ñandú strlen=7 mb_strlen=5
AñO ñANDú | AÑO ÑANDÚ

La última línea es una trampa clásica en formularios en español: strtoupper solo conoce ASCII y deja la ñ y la ú en minúscula. mb_strtoupper las convierte bien.

Hasta la terminal tiene dos respuestas. wc -c cuenta bytes; wc -m cuenta caracteres según tu locale:

$ printf 'año\n' > es.txt
$ wc -c es.txt            →  5 es.txt
$ wc -m es.txt            →  4 es.txt   (LANG=C.UTF-8)
$ LANG=C wc -m es.txt     →  5 es.txt

El mismo archivo, dos "cantidades de caracteres". La regla para toda la serie:

Necesitas… Python PHP Terminal
bytes para guardar, enviar o límites de columna len(s.encode("utf-8")) strlen($s) wc -c
code points len(s) mb_strlen($s) wc -m (locale UTF-8)
lo que el usuario llama "caracteres" ver la lección 5 grapheme_strlen($s) —

Error común: cortar bytes en medio de un carácter

Error. Recortas un nombre a "10 caracteres" con una función de bytes, o lees un buffer de red en bloques de tamaño fijo y decodificas cada bloque por separado.

Lo que pasa en realidad. El corte cae dentro de una secuencia multibyte. substr("año", 0, 2) en PHP devuelve los bytes 61 c3: una a y media ñ, que el navegador muestra como a�. Python falla en voz alta, y con errors="replace" falla en silencio:

b = "año".encode()[:2]
try:
    b.decode()
except UnicodeDecodeError as e:
    print(repr(b), e, "| replace:", repr(b.decode("utf-8", "replace")))
b'a\xc3' 'utf-8' codec can't decode byte 0xc3 in position 1: unexpected end of data | replace: 'a�'

Solución. Corta con funciones que entienden code points (s[:10] en Python, mb_substr en PHP; mb_substr("año", 0, 2) devolvió añ). Si lees bytes por bloques, usa un decodificador incremental que guarde el carácter a medias para el bloque siguiente; la lección 6 muestra ese bug y su arreglo con cortes reales. Si programas en C, el curso de strings y structs en C (en inglés) cubre el lado del terminador NUL.

ASCII es un subconjunto de UTF-8, y por qué ganó UTF-8

Cada byte ASCII significa lo mismo en UTF-8, y ningún byte de un carácter multibyte es menor que 0x80. Así que un archivo solo ASCII ya es UTF-8 válido, y además es idéntico en casi todas las codificaciones antiguas:

t = "Hello, World 123"
print(t.encode("ascii") == t.encode("utf-8"))                     # True
print(t.encode("ascii") == t.encode("latin-1") == t.encode("cp1252"))  # True
print(t.encode("utf-16-le")[:8].hex(" "))                          # 48 00 65 00 6c 00 6c 00

UTF-16 no es compatible con ASCII: cada letra lleva un byte 00 al lado, que las funciones de C leen como "fin del string". De ahí salen las razones por las que UTF-8 es hoy el estándar de la web, Linux, Git y JSON:

  1. Las herramientas viejas pensadas para ASCII siguen funcionando. Ningún 00 ni / (2F) aparece dentro de un carácter no ASCII. "año 👍" en UTF-8 no tiene ningún 00; en UTF-16 sí.
  2. No hay orden de bytes. UTF-16 necesita un BOM o una etiqueta para distinguir FF FE de FE FF ("Hi".encode("utf-16") dio b'\xff\xfeH\x00i\x00').
  3. Autosincronización, como vimos arriba.
  4. El orden de bytes es el orden de code points. Ordenar 10,000 strings aleatorios (seed 42) por sus bytes UTF-8 dio exactamente el mismo orden que ordenar los str.

¿Y el tamaño? Medí una oración corta por idioma y una página real de este sitio en cada idioma:

Texto Code points UTF-8 UTF-16 Antigua
Oración en español con ñ ¿ ¡ 91 104 182 91 (cp1252)
Oración en inglés con ’ “ ” é ï 95 103 190 95 (cp1252)
Oración en japonés 59 153 118 106 (cp932)
Oración en coreano 64 146 128 105 (cp949)

Para el español, UTF-8 cuesta muy poco: unos 14% más bytes que Windows-1252 en esta oración, porque solo las letras acentuadas, la ñ y los signos de apertura ocupan dos bytes. A cambio, puedes guardar €, comillas tipográficas, emoji y cualquier idioma en el mismo archivo.

Gráfico de barras del tamaño de una página. Versión en inglés: 32.6 KB en UTF-8 y 65.1 KB en UTF-16. Versión en español: 33.0 KB y 65.5 KB. Versión en japonés: 39.2 KB y 54.8 KB. Versión en coreano: 36.7 KB y 54.3 KB.
La misma página de un curso de este sitio guardada en UTF-8 y en UTF-16. El marcado es ASCII, así que UTF-8 es más pequeño incluso en japonés y coreano. Python 3.9, tamaños en KB (1,000 bytes).

Una página de curso de este sitio en español ocupó 33,032 bytes en UTF-8 y 65,468 en UTF-16; solo el 0.8% de sus caracteres no era ASCII. Incluso la versión en japonés fue más pequeña en UTF-8 (39,229 frente a 54,842 bytes), porque el HTML es ASCII. Según las encuestas de W3Techs, más del 98% de los sitios web usan UTF-8 (es su cifra; no la medimos aquí).

Límites honestos y notas de versión

  • unicodedata de Python 3.9.6 usa Unicode 13.0; con emoji muy recientes unicodedata.name puede lanzar ValueError: no such name. Los bytes UTF-8 no dependen de la versión de Unicode.
  • PHP 8.5.6 con mbstring; mb_internal_encoding() devolvió UTF-8. Si un php.ini antiguo fija otra codificación interna, los resultados cambian.
  • wc -m depende del locale (LANG). Usamos el wc de macOS; el wc de GNU no se ejecutó aquí.
  • UTF-8, tal como lo define el RFC 3629 (2003), termina en 4 bytes y U+10FFFF. Los textos viejos que describen formas de 5 y 6 bytes hoy son inválidos.
  • La tabla de tamaños son cuatro oraciones y cuatro páginas, no un corpus. Tómala como ilustración de la tendencia.

Ejercicios

  1. Sin ejecutar nada, predice len(s) y len(s.encode("utf-8")) para "¿Año?". ¿Cuántos bytes ocupa en Windows-1252?
  2. Un log muestra los bytes E2 82 AC. Decodifícalos a mano con la tabla de bits. Luego haz lo mismo con F0 9F 8E 89.
  3. Una columna admite "50 bytes". ¿Cuántas veces cabe "ñ" en UTF-8? ¿Y en Windows-1252?

Respuestas

  1. len 5 y 7 bytes en UTF-8 (c2 bf 41 c3 b1 6f 3f: ¿ y ñ ocupan 2 cada una). En Windows-1252 son 5 bytes, uno por carácter.
  2. E2 es 1110 0010, así que son 3 bytes: 0010 + 000010 + 101100 = U+20AC, €. F0 9F 8E 89 son 4 bytes y dan U+1F389, 🎉.
  3. 25 veces en UTF-8 (2 bytes cada una) y 50 en Windows-1252 (1 byte cada una).

Siguiente

Los bytes son fáciles cuando los puedes ver. El problema empieza cuando un programa escribe bytes con una codificación y otro los lee con otra.

Preguntas frecuentes

¿Por qué Python dice que len('año') es 3 si el archivo ocupa 4 bytes?

len() cuenta code points, y 'año' tiene tres. El archivo guarda UTF-8, donde la ñ (U+00F1) ocupa dos bytes, C3 B1, así que el total es 4 bytes. len(s.encode('utf-8')) devuelve la cantidad de bytes.

¿La ñ ocupa uno o dos bytes?

Depende de la codificación. En Latin-1 y Windows-1252 es un solo byte, F1. En UTF-8 son dos, C3 B1. Por eso un texto en español crece un poco al pasar a UTF-8: una oración de 91 code points pasó de 91 bytes en cp1252 a 104 en UTF-8.

¿strtoupper() de PHP no funciona con la ñ?

strtoupper solo conoce ASCII. En PHP 8.5 strtoupper('año ñandú') devolvió 'AñO ñANDú', mientras que mb_strtoupper devolvió 'AÑO ÑANDÚ'. Usa las funciones mb_ cuando trabajes con caracteres y no con bytes.

  • #codificación
  • #principiante
  • #UTF-8
  • #Unicode
  • #Python
  • #PHP

t.laboratory

A technical blog that records real problems from the apps, servers and services we build and run — and the code that fixed them.

Acerca de · Apps

↑