En Python 3, str y bytes son tipos fundamentalmente distintos, y esa distinción no es un capricho del diseño: es una de las decisiones más importantes que tomó el equipo del lenguaje cuando rompió compatibilidad con Python 2. Aquí no hay una versión “de texto” y una versión “binaria” del mismo tipo — son modelos de datos diferentes que resuelven problemas diferentes.
Un objeto str es una secuencia de puntos de código Unicode. No tiene codificación, no tiene bytes; es una abstracción pura sobre texto humano. Un objeto bytes, en cambio, es una secuencia de enteros entre 0 y 255. Cuando haces b"hola"[0], no obtienes "h" — obtienes 104. Eso te dice todo lo que necesitas saber sobre su naturaleza.
El puente entre los dos mundos es siempre un encoding (como utf-8, latin-1, ascii). Sin encoding no puedes cruzar, y Python se niega a adivinarlo por ti. Esta negativa es intencional: en Python 2 esa adivinanza era la fuente de bugs sutilísimos que explotaban en producción cuando llegaba texto en un idioma inesperado.
¿Cuándo te encuentras con bytes en código real? Cada vez que tocas I/O binario: leer un archivo con "rb", enviar o recibir datos por un socket TCP, calcular un hash con hashlib, trabajar con criptografía, procesar imágenes o audio, o hablar con una base de datos a nivel de driver. La red y el disco no saben qué es Unicode — ellos solo mueven bytes.
Lo que rompe cuando te equivocas es predecible y ruidoso: TypeError: can only concatenate str (not "bytes") to str. Python no hace coerción silenciosa. Si intentas mezclar tipos sin conversión explícita, falla inmediatamente. Eso es bueno — te está protegiendo de un bug de corrupción de datos.
import hashlib
import socket
from io import BytesIO, StringIO
# ── 1. La naturaleza de cada tipo ──────────────────────────────────────
texto: str = "Héroe" # secuencia de puntos de código Unicode
raw: bytes = b"H\xc3\xa9roe" # secuencia de enteros; utf-8 de "Héroe"
print(type(texto)) # <class 'str'>
print(type(raw)) # <class 'bytes'>
print(texto[0]) # 'H' → un carácter
print(raw[0]) # 72 → el entero 0x48, no un carácter
# ── 2. El puente: encode / decode ──────────────────────────────────────
encoded: bytes = texto.encode("utf-8")
print(encoded) # b'H\xc3\xa9roe'
print(encoded == raw) # True
decoded: str = encoded.decode("utf-8")
print(decoded == texto) # True
# Un encoding equivocado puede fallar o, peor, silenciosamente malinterpretar
try:
raw.decode("ascii") # 0xc3 está fuera del rango ASCII
except UnicodeDecodeError as e:
print(f"No puedes usar ascii aquí: {e}")
# ── 3. I/O binario: donde bytes es obligatorio ─────────────────────────
def sha256_of_text(text: str) -> str:
"""Hashlib opera sobre bytes, nunca sobre str."""
data: bytes = text.encode("utf-8") # conversión explícita antes del hash
digest = hashlib.sha256(data)
return digest.hexdigest() # hexdigest() devuelve str, cómodo para logs
print(sha256_of_text("contraseña secreta"))
# ── 4. Simulación de socket (sin red real) ────────────────────────────
def simulate_network_roundtrip(message: str) -> str:
"""
Los sockets TCP trabajan exclusivamente con bytes.
Aquí simulamos el encode en el emisor y el decode en el receptor.
"""
# El emisor serializa el str a bytes antes de enviar
payload: bytes = message.encode("utf-8")
# En un socket real: sock.sendall(payload)
# En el receptor: data = sock.recv(4096)
received: bytes = payload # simulamos la recepción
# El receptor reconstruye el str
return received.decode("utf-8")
result = simulate_network_roundtrip("¡Hola, mundo!")
print(result)
# ── 5. BytesIO vs StringIO ─────────────────────────────────────────────
def process_binary_in_memory(data: bytes) -> bytes:
"""
BytesIO actúa como un archivo binario en RAM.
Útil para procesar datos sin tocar el disco.
"""
buf = BytesIO()
buf.write(data)
buf.seek(0)
return buf.read()
def process_text_in_memory(text: str) -> str:
"""StringIO es el equivalente para str."""
buf = StringIO()
buf.write(text)
buf.seek(0)
return buf.read()
binary_result = process_binary_in_memory(b"\x00\x01\x02\xff")
text_result = process_text_in_memory("texto en memoria")
print(binary_result) # b'\x00\x01\x02\xff'
print(text_result) # texto en memoria
# ── 6. El error clásico: pasar str donde se esperan bytes ─────────────
try:
buf = BytesIO()
buf.write("esto falla") # BytesIO solo acepta bytes
except TypeError as e:
print(f"TypeError esperado: {e}")
try:
buf = StringIO()
buf.write(b"esto también falla") # StringIO solo acepta str
except TypeError as e:
print(f"TypeError esperado: {e}")
Lo que hace cada decisión de diseño
El ejemplo del indexado — raw[0] devuelve 72, no "H" — no es un detalle menor. Es la diferencia conceptual completa. bytes es un buffer de memoria; str es texto con semántica. Cuando iteras sobre bytes, obtienes enteros. Cuando iteras sobre str, obtienes caracteres. Son protocolos distintos.
En sha256_of_text, la conversión con .encode("utf-8") antes de pasarle los datos a hashlib es obligatoria. hashlib.sha256() no acepta str — exige saber qué bytes representan ese texto, porque el hash es una función sobre secuencias de bytes, no sobre abstracciones Unicode. Si cambias utf-8 por utf-16, obtendrás un hash completamente diferente para el mismo texto. El encoding forma parte de la identidad del dato serializado.
La simulación de socket muestra el patrón que vas a repetir infinitamente en código de red: encode en el borde de salida, decode en el borde de entrada. El interior de tu aplicación trabaja con str; la red solo ve bytes. Mantener esa frontera explícita hace que el código sea más fácil de razonar y de depurar.
BytesIO y StringIO son gemelos que no se intercambian. BytesIO implementa el mismo protocolo de archivo que open("archivo", "rb") — acepta y produce bytes. StringIO implementa el protocolo de open("archivo", "r") — acepta y produce str. Si tu función espera un objeto tipo-archivo de texto y le pasas un BytesIO, fallará en el primer .read() que intente comparar el resultado con un str. La elección entre ellos depende exclusivamente de con qué tipo estás trabajando en ese contexto.
Errores que debes conocer
Error: Intentar concatenar o comparar str con bytes directamente, asumiendo que Python hará la conversión automáticamente.
# ❌ Wrong
prefix = "data: "
raw = b"payload"
result = prefix + raw # TypeError inmediato
# ✅ Right
result = prefix + raw.decode("utf-8")
# o si necesitas bytes en todo el pipeline:
result_bytes = prefix.encode("utf-8") + raw
Decide en cuál de los dos mundos vive tu pipeline y convierte en el borde, no a mitad del camino.
Error: Abrir un archivo binario sin el modo "b" y encontrarse con str cuando se necesitan bytes (o viceversa).
# ❌ Wrong
with open("imagen.png", "r") as f: # modo texto → devuelve str
data = f.read() # UnicodeDecodeError o datos corruptos
hashlib.md5(data) # TypeError si no falló antes
# ✅ Right
with open("imagen.png", "rb") as f: # modo binario → devuelve bytes
data = f.read()
hashlib.md5(data)
Para cualquier archivo que no sea texto plano, "rb"/"wb" no es opcional.
Error: Usar un encoding incorrecto al decodificar y obtener datos silenciosamente incorrectos en lugar de un error.
# ❌ Wrong — no falla pero el resultado es basura
garbled = "caf\xe9".encode("latin-1").decode("utf-8", errors="replace")
# produce 'caf\ufffd' en vez de 'café'
# ✅ Right — falla ruidosamente si el encoding no cuadra
correct = "café".encode("utf-8").decode("utf-8")
errors="replace" es útil para logging o diagnóstico, nunca para datos que vas a procesar.
N° 38