Generator y yield en Python: procesar datos grandes sin gastar RAM

1. Resumen

Cuando procesas archivos de log de varios GB o streams de millones de registros desde una base de datos, escribir código que “carga todo en una lista y luego procesa” hará que tu programa consuma RAM sin control y posiblemente falle. Este artículo te ayudará a entender cómo funcionan los generator y yield, a compararlos con las listas normales, y a aplicarlos a problemas reales como leer archivos grandes o construir pipelines de procesamiento de datos. Después de leerlo, podrás escribir generators correctamente y sabrás cuándo NO deberías usarlos.

2. El problema con el enfoque tradicional

Supón que necesitas leer un archivo de log de 5GB y contar las líneas que contienen la palabra “ERROR”:

def count_errors_bad(filepath: str) -> int:
    with open(filepath, "r") as f:
        lines = f.readlines()  # carga TODO el archivo en RAM
    return sum(1 for line in lines if "ERROR" in line)

f.readlines() lee el contenido completo del archivo en una lista de strings en memoria. Con un archivo de 5GB, Python asignará ~5GB+ de RAM (más el overhead de los objetos) antes de empezar a contar. Si tu máquina solo tiene 4GB de RAM, el programa fallará con MemoryError o el sistema se colgará por swap.

El problema de fondo: solo necesitas procesar una línea a la vez, pero el código obliga a la máquina a mantener todas las líneas simultáneamente.

3. ¿Qué es un generator?

Un generator es un tipo especial de iterable: genera valores bajo demanda (evaluación lazy) en lugar de calcular y almacenar todo de antemano. En lugar de retornar una lista completa, un generator retorna un valor a la vez, y solo calcula el siguiente cuando se le solicita.

def count_errors_good(filepath: str) -> int:
    with open(filepath, "r") as f:
        return sum(1 for line in f if "ERROR" in line)

Aquí f (el objeto de archivo) por sí mismo es un iterador — lee una línea a la vez cuando el bucle lo solicita, sin cargar el archivo completo en RAM. La memoria utilizada es solo una línea a la vez, sin importar si el archivo tiene 5GB o 50GB.

4. La palabra clave yield: cómo crear una generator function

Puedes escribir tu propio generator usando yield dentro de una función. Cuando una función contiene yield, Python automáticamente la convierte en una generator function — llamarla no ejecuta el código inmediatamente, sino que retorna un objeto generator.

def read_large_file(filepath: str):
    with open(filepath, "r") as f:
        for line in f:
            yield line.strip()
  • yield line.strip() pausa la función aquí, retornando el valor line.strip() hacia afuera.
  • La siguiente llamada a next(), la función continúa ejecutándose desde donde se pausó, no se reinicia desde el principio.
  • El estado de la función (variables locales, posición del puntero del archivo) se mantiene entre cada yield.

Pruébalo:

gen = read_large_file("access.log")
print(type(gen))
print(next(gen))
print(next(gen))
<class 'generator'>
127.0.0.1 - GET /index.html 200
127.0.0.1 - GET /style.css 200

Nota: la línea gen = read_large_file(...) no abre el archivo, no lee nada. El código interno solo se ejecuta cuando llamas a next(gen) por primera vez.

5. Comparación de RAM: generator vs lista

Para ver claramente la diferencia, usa el módulo sys para medir el tamaño de los objetos:

import sys

def squares_list(n: int) -> list[int]:
    return [i * i for i in range(n)]

def squares_gen(n: int):
    for i in range(n):
        yield i * i

big_list = squares_list(1_000_000)
big_gen = squares_gen(1_000_000)

print(f"List size: {sys.getsizeof(big_list):,} bytes")
print(f"Generator size: {sys.getsizeof(big_gen):,} bytes")
List size: 8,448,728 bytes
Generator size: 208 bytes

Una lista con 1 millón de elementos ocupa casi 8.5MB, mientras que un generator es solo un objeto pequeño de 208 bytes — porque no almacena valores, solo almacena el estado para calcular el siguiente cuando sea necesario.

Nota: Un generator solo se puede recorrer una vez. Después de agotarse, llamar a next() nuevamente lanzará StopIteration. Si necesitas recorrer varias veces, debes crear un nuevo generator llamando la función de nuevo.

6. Generator expressions: sintaxis más compacta

Similar a las list comprehensions pero usando paréntesis en lugar de corchetes, tienes generator expressions — sin necesidad de definir una función separada:

numbers = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]

even_squares_gen = (n * n for n in numbers if n % 2 == 0)
print(sum(even_squares_gen))
220

Los paréntesis () en lugar de [] hacen que Python cree un generator en lugar de una lista inmediatamente. Esta forma es útil cuando solo necesitas usar los valores una vez, por ejemplo pasarlos directamente a sum(), max(), o any().

7. Construir pipelines de procesamiento de datos con generators

La fortaleza de los generators es poder encadenar múltiples pasos de procesamiento sin crear listas intermedias. Supón que necesitas: leer archivo CSV de órdenes → filtrar órdenes con status = "PAID" → calcular la suma de amount.

import csv

def read_orders(filepath: str):
    with open(filepath, newline="") as f:
        reader = csv.DictReader(f)
        for row in reader:
            yield row

def filter_paid(orders):
    for order in orders:
        if order["status"] == "PAID":
            yield order

def extract_amount(orders):
    for order in orders:
        yield float(order["amount"])

def total_paid_amount(filepath: str) -> float:
    orders = read_orders(filepath)
    paid_orders = filter_paid(orders)
    amounts = extract_amount(paid_orders)
    return sum(amounts)

Cada función read_orders, filter_paid, extract_amount es un generator — no procesan todos los datos inmediatamente, sino que se “conectan” entre sí formando un pipeline. Cuando sum(amounts) llama a next(), el valor se extrae a través de cada paso: leer 1 fila CSV → revisar el status → convertir a float → acumular. En un momento dado, solo un registro existe en memoria, aunque el archivo CSV tenga 10 filas o 10 millones.

7.1. Probar el pipeline con pytest

import pytest
from pathlib import Path

@pytest.fixture
def orders_csv(tmp_path: Path) -> str:
    content = (
        "order_id,amount,status\n"
        "1,100.0,PAID\n"
        "2,50.0,PENDING\n"
        "3,200.0,PAID\n"
    )
    file_path = tmp_path / "orders.csv"
    file_path.write_text(content)
    return str(file_path)

def test_total_paid_amount(orders_csv):
    result = total_paid_amount(orders_csv)
    assert result == pytest.approx(300.0)

tmp_path es una fixture integrada de pytest que crea un directorio temporal único para cada prueba. Esta prueba crea un archivo CSV simulado con 3 órdenes, solo 2 con estado PAID, y verifica que la suma sea exactamente 300.0.

8. yield from: delegación a otro generator

Cuando un generator necesita llamar y anidar resultados de otro generator, yield from ayuda a escribir código más compacto en lugar de escribir un bucle manual.

def read_file_lines(filepath: str):
    with open(filepath) as f:
        for line in f:
            yield line.strip()

def read_multiple_files(filepaths: list[str]):
    for filepath in filepaths:
        yield from read_file_lines(filepath)

yield from read_file_lines(filepath) es equivalente a escribir for line in read_file_lines(filepath): yield line, pero es más compacto y maneja correctamente incluso el caso donde el generator interno retorna un valor. La función read_multiple_files te permite recorrer múltiples archivos de log como si fueran un stream continuo, sin necesidad de fusionar listas.

9. Enviar valores a un generator con send()

Los generators no solo generan valores hacia afuera — también puedes enviar valores hacia adentro usando send(), convirtiéndolos en corrutinas simples.

def running_average():
    total = 0.0
    count = 0
    average = None
    while True:
        value = yield average
        total += value
        count += 1
        average = total / count

avg_gen = running_average()
next(avg_gen)              # inicia el generator, ejecuta hasta el primer yield
print(avg_gen.send(10))    # envía 10, average = 10/1
print(avg_gen.send(20))    # envía 20, average = 30/2
print(avg_gen.send(30))    # envía 30, average = 60/3
10.0
15.0
20.0

La línea value = yield average hace dos cosas: retorna average hacia afuera, mientras simultáneamente espera recibir un valor a través de send() para asignarlo a value. Debes llamar a next(avg_gen) una primera vez para llevar el generator al primer punto yield — si llamas a send() inmediatamente sin haber iniciado, Python lanzará TypeError.

Nota: Usar send() hace el código más difícil de leer que un generator normal. En la práctica, frameworks como asyncio ya han abstraído este mecanismo; rara vez necesitarás escribir send() por tu cuenta a menos que estés construyendo una biblioteca.

10. Cuándo NO deberías usar generators

Los generators no siempre son mejores que las listas:

  • Necesitas acceso aleatorio o recorrer múltiples veces: los generators no soportan gen[5] o recorrer de nuevo desde el principio. Si necesitas len(), slicing, o procesar datos 2-3 veces, una lista es más apropiada.
  • Los datos son pequeños, sin preocupaciones de RAM: con unos pocos miles de elementos, el overhead de gestionar el estado del generator a veces es más lento que una list comprehension porque las listas están optimizadas a nivel C.
  • Necesitas paralelismo o dividir el trabajo entre múltiples workers: las listas se dividen fácilmente en chunks para enviar a multiprocessing; los generators deben “materializarse” parcialmente antes de dividirse.
  • El debugging es más difícil: imprimir una lista muestra inmediatamente todos los valores; imprimir un generator solo muestra <generator object ... at 0x...>, y para ver los valores debes recorrerlo (y una vez recorrido, el generator se “agota” y no se puede reutilizar).

Si no estás seguro, la regla simple es: usa list cuando los datos caben en RAM y necesitas usarlos varias veces; usa generator cuando los datos son grandes, solo necesitas procesarlos secuencialmente una vez, o estás construyendo un pipeline de streaming.

11. Conclusión

Los generators y yield resuelven el problema correcto: procesar datos grandes sin cargar todo en RAM, gracias al mecanismo de evaluación lazy — calculando valores solo cuando se solicitan. Ya has visto cómo escribir generator functions, generator expressions, encadenar múltiples generators en un pipeline usando yield from, e incluso cómo enviar datos hacia atrás a un generator con send(). Recuerda el tradeoff: los generators ahorran memoria pero a cambio pierdes la capacidad de recorrer de nuevo, acceso aleatorio, y a veces el código es más difícil de leer. Elige la herramienta correcta para el trabajo correcto, no uses generators en todas partes solo porque “ahorran RAM”.

pythongeneratoryieldperformancememory