Cuando escribes "café" en Python, no estás manipulando bytes. Estás manipulando code points: números abstractos que el estándar Unicode asigna a cada carácter de todos los sistemas de escritura del planeta. La é tiene el code point U+00E9, la 漢 tiene U+6F22, el emoji 🐍 tiene U+1F40D. Python no sabe de bytes cuando trabaja con str — eso es un detalle que aparece solo cuando serializas a disco o red.
Esto es un cambio de mentalidad importante respecto a lenguajes donde un string es literalmente un array de bytes. En Python 3, str es una secuencia inmutable de code points Unicode, y len() te devuelve exactamente eso: cuántos code points hay, no cuántos bytes ocuparía en memoria o en disco.
len("café") # → 4, cuatro code points: c, a, f, é
len("café".encode("utf-8")) # → 5, cinco bytes en UTF-8
La é ocupa un solo code point (U+00E9) pero dos bytes en UTF-8. Si alguna vez te ha fallado un truncado de texto o un índice en un string que contenía acentos, ahí estaba el problema: confundir bytes con code points.
La inmutabilidad es la otra mitad del concepto. Una vez que creas un string, no puedes cambiar ninguno de sus caracteres. Puedes construir strings nuevos a partir de operaciones sobre el original, pero el objeto original nunca muta. Esto le permite a Python hacer optimizaciones como el interning (reutilizar el mismo objeto en memoria para strings idénticos) y garantiza que pasar un string a una función sea seguro sin hacer copias defensivas.
Ahora bien, ¿cómo almacena CPython estos code points en memoria? No usa UTF-8 internamente — eso sería lento para indexar porque UTF-8 tiene ancho variable. CPython usa tres representaciones posibles según el code point más grande que contenga el string:
- Latin-1 (1 byte/carácter): si todos los code points caben en U+00FF.
- UCS-2 (2 bytes/carácter): si el code point máximo está entre U+0100 y U+FFFF.
- UTF-32 / UCS-4 (4 bytes/carácter): si hay algún code point por encima de U+FFFF (emojis, caracteres CJK raros, etc.).
Esta estrategia, llamada PEP 393 / Flexible String Representation, garantiza que s[i] sea O(1) — acceso directo por índice — porque todos los caracteres ocupan el mismo ancho dentro de un string concreto.
import sys s_ascii = "hello" # Latin-1 internamente s_latin = "café" # Latin-1 internamente (é ≤ U+00FF) s_cjk = "漢字" # UCS-2 internamente s_emoji = "Python 🐍" # UCS-4 internamente print(sys.getsizeof(s_ascii)) # ~54 bytes print(sys.getsizeof(s_latin)) # ~57 bytes (4 chars × 1 byte + overhead) print(sys.getsizeof(s_cjk)) # ~76 bytes (2 chars × 2 bytes + overhead) print(sys.getsizeof(s_emoji)) # ~120 bytes (8 chars × 4 bytes + overhead)
# ── Strings como secuencias: todo lo que funciona en listas funciona aquí ──
text = "Pythön rocks 🎸"
# Indexación — acceso O(1) gracias a la representación de ancho fijo
print(text[0]) # 'P'
print(text[-1]) # '🎸' ← un solo code point, no bytes
# Slicing — devuelve un nuevo string (el original no muta)
print(text[0:6]) # 'Pythön'
# Iteración sobre code points, no sobre bytes
for char in text:
cp = ord(char) # ord() devuelve el número de code point
print(f" '{char}' → U+{cp:04X}")
# len() cuenta code points
print(len(text)) # 14, no importa cuántos bytes use UTF-8
# El string no muta; esto lanza TypeError
try:
text[0] = "p"
except TypeError as e:
print(e) # 'str' object does not support item assignment
# Para "modificar" un string, construyes uno nuevo
lower_text = text[0].lower() + text[1:]
print(lower_text) # 'pythön rocks 🎸'
# chr() es el inverso de ord(): de code point a carácter
print(chr(0x1F3B8)) # '🎸'
print(chr(0x00F6)) # 'ö'
Lo que revela el código
La iteración for char in text recorre code points, no bytes ni unidades UTF-16. Eso significa que '🎸' es un único elemento en el loop, aunque en un array de bytes UTF-8 ocuparía cuatro posiciones. Fíjate que ord() y chr() son las herramientas para moverse entre el plano abstracto (números) y los caracteres concretos — son simétricas.
El slicing text[0:6] devuelve 'Pythön' correctamente porque internamente CPython ya sabe que ese string usa UCS-2 (la ö tiene code point U+00F6, mayor que U+00FF), así que cada posición de índice equivale a 2 bytes exactos. El índice es siempre sobre code points, la representación física es un detalle del intérprete.
El bloque try/except no es solo demostrativo: la inmutabilidad implica que cualquier “modificación” produce un objeto nuevo. Esto tiene consecuencias en rendimiento cuando concatenas en un loop — cada += crea un string nuevo — pero también garantiza que dos variables que apuntan al mismo string no se interfieran mutuamente.
La diferencia de tamaño que muestra sys.getsizeof es una ventaja tangible de PEP 393: un texto en inglés puro usa 1 byte por carácter, igual que ASCII, y solo paga el costo extra cuando realmente lo necesita.
Errores que debes conocer
Error: Usar len() sobre bytes esperando el número de caracteres, o viceversa.
# ❌ Wrong
text = "café"
raw = text.encode("utf-8")
print(len(raw)) # 5 — son bytes, no caracteres
# ✅ Right
print(len(text)) # 4 — code points
print(len(raw)) # 5 — bytes, úsalo solo cuando necesites el tamaño en bytes
len() sobre un bytes object cuenta bytes; sobre un str cuenta code points. Son tipos distintos en Python 3 — no son intercambiables.
Error: Indexar con lógica de bytes en strings que contienen caracteres fuera de ASCII.
# ❌ Wrong — asumir que truncar en índice 4 da "caf" sin cortar a la mitad de nada
text = "café"
truncated = text.encode("utf-8")[:4] # b'caf\xc3' — bytes incompletos de é
print(truncated.decode("utf-8")) # UnicodeDecodeError
# ✅ Right — truncar sobre code points, luego codificar
truncated = text[:3].encode("utf-8") # b'caf' — tres code points completos
print(truncated.decode("utf-8")) # 'caf'
Siempre trunca o indexa en el dominio de str (code points); solo convierte a bytes cuando vas a serializar, y hazlo como último paso.
N° 34