⏱️ Lectura: 14 min
Una lista de un millón de números carga el millón completo en memoria antes de que puedas usar el primero. Una función con yield no: entrega un valor, se congela ahí mismo y solo continúa cuando alguien se lo pide. Esa pausa controlada es la base de los generadores en Python, uno de los mecanismos más usados y menos entendidos del lenguaje.
📑 En este artículo
- TL;DR
- Qué son los generadores en Python y por qué importan
- Cómo funciona yield en detalle
- Ejemplos prácticos: de lo simple a lo real
- Cómo empezar: convertir una función en generador paso a paso
- Casos de uso reales
- Errores comunes y buenas prácticas
- Comparativa: lista, expresión generadora y función con yield
- Profundizando: send(), yield from y generadores asíncronos
- Preguntas frecuentes
- Referencias
Este artículo explica qué hace exactamente la palabra clave yield, cómo funcionan los generadores en Python por dentro, y cuándo conviene usar uno en vez de una lista o una función normal.
TL;DR
- Entenderás cómo yield pausa una función y la reanuda exactamente donde quedó, sin perder su estado.
- Vas a poder construir generadores propios para leer archivos enormes línea por línea sin cargarlos completos en RAM.
- Sabrás usar next() y send() para controlar manualmente un generador y el protocolo de iteración.
- Vas a distinguir cuándo conviene una lista, una expresión generadora o una función con yield.
- Aprenderás a evitar el error de recorrer un generador agotado y por qué solo se puede usar una vez.
- Vas a conocer yield from para delegar en subgeneradores y simplificar pipelines de datos.
- Entenderás la diferencia entre un generador síncrono y uno asíncrono con async def y async for.
Qué son los generadores en Python y por qué importan
Un generador es una función que, en lugar de devolver un único resultado con return y terminar, va entregando valores uno a la vez con yield y se suspende entre cada entrega. La función no vuelve a ejecutarse desde el principio: retoma exactamente en la línea siguiente al último yield, con todas sus variables locales intactas.
La diferencia práctica es memoria y tiempo. Una función que arma una lista con return tiene que calcular y guardar todos los elementos antes de devolver algo. Un generador equivalente con yield produce el primer elemento de inmediato y calcula el resto solo si alguien lo pide. Esto se llama evaluación perezosa (lazy evaluation).
Los generadores en Python existen desde la versión 2.2, definidos en la PEP 255: Simple Generators. Hoy son la base de módulos completos de la librería estándar, como itertools, y del propio protocolo de iteración que usa cualquier bucle for.
flowchart TD
A["Funcion con return []"] --> B["Construye la lista completa"]
B --> C[("Toda la lista en RAM")]
D["Funcion con yield"] --> E["Genera un valor a la vez"]
E --> F[("Solo el valor actual en RAM")]
Cómo funciona yield en detalle
Cuando Python encuentra un yield dentro del cuerpo de una función, esa función deja de ser una función normal: se convierte en una función generadora. Llamarla no ejecuta ni una línea de su cuerpo; solo crea y devuelve un objeto generador.
def contar_hasta(n):
print("arrancando el generador")
i = 1
while i <= n:
yield i
i += 1
g = contar_hasta(3)
print(g)
Ejecutar este código imprime algo como <generator object contar_hasta at 0x...>. El print("arrancando el generador") todavía no se ejecutó: llamar a contar_hasta(3) solo crea el objeto generador, no corre el cuerpo de la función.
El cuerpo recién arranca cuando alguien pide el primer valor con la función next():
print(next(g)) # arrancando el generador -> 1
print(next(g)) # 2
print(next(g)) # 3
print(next(g)) # StopIteration
Cada llamada a next(g) corre el cuerpo hasta el siguiente yield, entrega ese valor y se suspende ahí. Cuando la función termina, Python lanza automáticamente la excepción StopIteration, la misma que un bucle for usa internamente para saber cuándo parar.
💭 Clave: un bucle for nunca llama a next() a mano: internamente hace exactamente next(iterador) en cada vuelta y sale cuando captura StopIteration. Los generadores no son un caso especial, son el mecanismo que hace funcionar a for.
stateDiagram-v2
[*] --> Creado
Creado --> Suspendido : primer next()
Suspendido --> Ejecutando : next() o send()
Ejecutando --> Suspendido : yield entrega un valor
Ejecutando --> Agotado : termina la funcion
Agotado --> [*]
Ejemplos prácticos: de lo simple a lo real
Generador mínimo
El generador más simple posible entrega un solo valor y termina:
def saludo():
yield "hola desde un generador"
for mensaje in saludo():
print(mensaje)
El bucle for llama a next() por vos, imprime el mensaje y, en la segunda vuelta, recibe StopIteration y sale del bucle sin lanzar error.
Fibonacci sin límite
Un generador puede representar una secuencia infinita porque nunca la calcula toda de una vez:
def fibonacci():
a, b = 0, 1
while True:
yield a
a, b = b, a + b
fib = fibonacci()
primeros_10 = [next(fib) for _ in range(10)]
print(primeros_10) # [0, 1, 1, 2, 3, 5, 8, 13, 21, 34]
Con una lista esto sería imposible: while True nunca termina, así que una lista jamás se completaría. Con yield, cada número existe solo en el instante en que se pide.
Leer un archivo grande línea por línea
El caso de uso más común en producción es procesar archivos que no entran en memoria:
def lineas_que_contienen(ruta, palabra):
with open(ruta, encoding="utf-8") as archivo:
for linea in archivo:
if palabra in linea:
yield linea.strip()
for coincidencia in lineas_que_contienen("logs.txt", "ERROR"):
print(coincidencia)
Esta función nunca tiene el archivo completo en memoria. Aunque logs.txt pese varios gigabytes, el generador solo mantiene una línea a la vez, porque iterar el objeto archivo con for linea in archivo ya es, en sí mismo, perezoso.
Expresión generadora: la versión compacta
Cuando la lógica es simple, no hace falta definir una función completa. Una expresión generadora hace lo mismo entre paréntesis en vez de corchetes:
cuadrados = (x * x for x in range(1_000_000))
suma = sum(cuadrados)
print(suma)
Cambiar los paréntesis () por corchetes [] convertiría esto en una lista de un millón de enteros construida en memoria antes de sumar nada. Con paréntesis, sum() pide un valor a la vez y nunca hay más de un entero calculado en simultáneo.
💡 Tip: si solo necesitás recorrer los datos una vez y la lógica es simple, preferí una expresión generadora antes que definir una función completa con yield: menos código para el mismo resultado.
Cómo empezar: convertir una función en generador paso a paso
Para pasar de una función normal a un generador no hace falta instalar nada; yield es parte del lenguaje desde Python 2.2. Los pasos son siempre los mismos:
- Identificá dónde tu función arma una lista con
return, por ejemploresultado.append(x)seguido dereturn resultado. - Borrá la lista intermedia (
resultado = []y el.append). - Reemplazá cada
resultado.append(x)poryield x. - Borrá el
return resultadofinal; un generador termina solo cuando la función termina de ejecutarse.
# antes: construye toda la lista en memoria
def pares_hasta(n):
resultado = []
for x in range(n):
if x % 2 == 0:
resultado.append(x)
return resultado
# despues: entrega los pares uno a uno
def pares_hasta_gen(n):
for x in range(n):
if x % 2 == 0:
yield x
Para consumir cualquiera de las dos versiones el código que las llama no cambia: ambas funcionan dentro de un for, con list(...) o con sum(...). Podés confirmar que pares_hasta_gen es un generador con:
import types
print(isinstance(pares_hasta_gen(10), types.GeneratorType)) # True
Casos de uso reales
Los generadores aparecen en cualquier lugar donde el volumen de datos es grande, desconocido de antemano o potencialmente infinito:
- Pipelines de datos: encadenar varios generadores, leer archivo, filtrar, transformar y agregar, procesa cada fila una sola vez sin materializar resultados intermedios completos.
- Paginación de APIs: un generador puede pedir la página siguiente de una API solo cuando el consumidor pide el próximo elemento, sin traer de antemano todas las páginas.
- ORMs y querysets: frameworks como Django devuelven objetos que se comportan como generadores para no cargar toda una tabla de base de datos en RAM al iterarla.
- itertools: el módulo itertools de la librería estándar está construido casi enteramente sobre generadores, con funciones como
itertools.count(),itertools.chain()eitertools.islice(). - Streaming de eventos: sistemas que leen un socket o una cola de mensajes usan generadores para procesar eventos a medida que llegan, sin esperar a tener todos los eventos.
Errores comunes y buenas prácticas
El error más frecuente es intentar recorrer un generador dos veces. A diferencia de una lista, un generador es de un solo uso:
numeros = (x for x in range(3))
print(list(numeros)) # [0, 1, 2]
print(list(numeros)) # [] , ya esta agotado
Si necesitás recorrer los mismos datos varias veces, la solución no es arreglar el generador: es decidir si en ese caso conviene directamente una lista, o volver a crear el generador desde cero llamando otra vez a la función.
⚠️ Ojo: llamar a next() sobre un generador ya agotado no reinicia nada, sigue lanzando StopIteration para siempre. No hay forma de rebobinar un generador.
Otro error común es mezclar return valor dentro de un generador esperando que ese valor salga por un for. En un generador, return valor no entrega ese valor al que itera: solo termina la función, y ese valor queda disponible como atributo .value de la excepción StopIteration, algo pensado específicamente para yield from, no para uso normal.
Buenas prácticas: nombrá las funciones generadoras de forma que se note que devuelven una secuencia perezosa, evitá abrir recursos costosos fuera del generador si puede no consumirse nunca, y usá with dentro del cuerpo del generador para que el recurso se cierre incluso si el consumidor deja de iterar a mitad de camino.
Comparativa: lista, expresión generadora y función con yield
| Opción | Cuándo usarla | Ventaja | Limitación |
|---|---|---|---|
| Lista | Datos pequeños que se recorren varias veces | Se puede indexar, recorrer N veces y usar len() | Todo el contenido vive en memoria a la vez |
| Expresión generadora | Una sola pasada sobre datos grandes, lógica simple | Sintaxis compacta y perezosa | Se agota tras un uso, no admite indexado |
| Función con yield | Lógica compleja, múltiples pasos o estado entre iteraciones | Perezosa y reutilizable como fábrica de generadores | Cada llamada crea un generador nuevo, no comparte estado entre llamadas |
| Generador asíncrono | Datos que llegan de I/O asíncrona, sockets o APIs | No bloquea el event loop mientras espera datos | Solo se recorre con async for, no con for normal |
Profundizando: send(), yield from y generadores asíncronos
Un generador no solo entrega valores hacia afuera: también puede recibir valores desde afuera con send(). Esto se agregó en la PEP 342: Coroutines via Enhanced Generators, incluida en Python 2.5, y convirtió a los generadores en la base de las corrutinas antes de que existiera async/await.
def acumulador():
total = 0
while True:
valor = yield total
total += valor
acc = acumulador()
next(acc) # arranca el generador, entrega total=0
print(acc.send(10)) # total ahora es 10, entrega 10
print(acc.send(5)) # total ahora es 15, entrega 15
Acá yield total hace dos cosas a la vez: entrega total hacia afuera y, cuando el generador se reanuda, esa misma expresión se evalúa al valor que llegó por send(). Esa doble vía es lo que distingue a yield como expresión de yield como sentencia simple.
sequenceDiagram
participant Main as Programa principal
participant Gen as Generador
Main->>Gen: next(acc)
Gen-->>Main: yield total = 0
Main->>Gen: acc.send(10)
Gen-->>Main: yield total = 10
Main->>Gen: acc.send(5)
Gen-->>Main: yield total = 15
Cuando un generador necesita delegar trabajo a otro generador, yield from evita escribir un bucle manual de reenvío. Se agregó en la PEP 380, parte de Python 3.3:
def generador_interno():
yield 1
yield 2
def generador_externo():
yield from generador_interno()
yield 3
print(list(generador_externo())) # [1, 2, 3]
Por último, Python extendió el mismo concepto al mundo asíncrono con la PEP 525: Asynchronous Generators, disponible desde Python 3.6. Un generador asíncrono se define con async def y se recorre con async for en vez de for:
import asyncio
async def leer_paginas(cliente, url):
pagina = 1
while True:
datos = await cliente.get(f"{url}?page={pagina}")
if not datos:
break
yield datos
pagina += 1
async def main():
async for pagina in leer_paginas(cliente, "https://api.ejemplo.com/items"):
print(pagina)
La diferencia con un generador normal es que cada yield puede estar precedido por un await que cede el control al event loop mientras espera una respuesta de red, sin bloquear el resto del programa.
Para confirmar en la práctica qué tipo de objeto tenés delante, el módulo inspect ofrece funciones específicas:
import inspect
print(inspect.isgeneratorfunction(fibonacci)) # True
print(inspect.isasyncgenfunction(leer_paginas)) # True
📖 Resumen en Telegram: Ver resumen
Tu próximo paso: abrí una terminal de Python, escribí un generador que lea un archivo de texto propio línea por línea y filtre solo las que contengan una palabra clave, y confirmá con isinstance(generador, types.GeneratorType) que efectivamente es perezoso.
Preguntas frecuentes
¿Qué diferencia hay entre yield y return?
Return termina la función y entrega un único valor final. Yield entrega un valor y suspende la función sin terminarla, dejando todas las variables locales intactas para la próxima llamada a next().
¿Un generador consume menos memoria siempre?
Consume menos memoria cuando no necesitás todos los valores a la vez o cuando la secuencia es muy grande o infinita. Si vas a recorrer los mismos datos varias veces o necesitás indexarlos, una lista suele ser más práctica.
¿Puedo convertir un generador en lista?
Sí, envolviéndolo con list(generador). Eso consume el generador por completo y guarda todos sus valores en memoria, perdiendo la ventaja de la evaluación perezosa.
¿Qué es una expresión generadora?
Es la versión compacta de un generador, escrita entre paréntesis en vez de corchetes, por ejemplo (x*x for x in datos). Se comporta igual que una función con yield pero sin necesidad de definir una función aparte.
¿Cuándo conviene un generador asíncrono?
Cuando cada valor depende de una operación de entrada/salida que tarda, como una llamada de red o una lectura de base de datos, y no querés bloquear el resto del programa mientras esperás esa respuesta.
¿Los generadores son seguros entre hilos?
No están pensados para compartirse entre hilos: un generador mantiene un único punto de ejecución, y llamar a next() desde varios hilos al mismo tiempo produce comportamiento indefinido. Cada hilo debería tener su propio generador.
Referencias
- Stack Overflow: What does the yield keyword do in Python?: la pregunta con miles de votos que popularizó la explicación del protocolo de generadores.
- Python Docs: Glossary, término generator: la definición oficial de generador y función generadora en la documentación de Python.
- PEP 255: Simple Generators: la propuesta original que introdujo yield en Python 2.2.
- PEP 525: Asynchronous Generators: la propuesta que agregó generadores asíncronos en Python 3.6.
📱 ¿Te gusta este contenido? Únete a nuestro canal de Telegram @programacion donde publicamos a diario lo más relevante de tecnología, IA y desarrollo. Resúmenes rápidos, contenido fresco todos los días.
Imagen destacada: Foto de Safar Safarov en Unsplash
0 Comentarios