Python & Data Science

Generadores de Python: Cómo procesar conjuntos de datos masivos sin colapsar tu computadora

El muro de ‘memoria llena’

¿Alguna vez has intentado abrir un archivo CSV enorme, solo para ver tu computadora convertirse en un pisapapeles muy caro? El cursor se congela, el ventilador zumba como un motor a reacción, y finalmente Python lanza ese temido mensaje: MemoryError.

Esto es lo que ocurre por debajo. Cuando creas una lista estándar de Python, le estás pidiendo a tu computadora que construya todo el producto terminado y lo almacene en tu RAM. Es como cocinar una comida para 1,000 personas y poner todos los platos en tu encimera de cocina al mismo tiempo. A menos que tengas una cocina enorme, te quedarás sin espacio.

Ahora veamos qué ocurre cuando creamos una lista con 50 millones de números.

import sys

try:
    # Creating a list of 50 million integers
    massive_list = [i for i in range(50000000)]
    print(f"List size: {sys.getsizeof(massive_list) / (1024**2):.2f} MB")
except MemoryError:
    print("Your computer just ran out of RAM!")
- `sys.getsizeof(obj)` devuelve el tamaño de un objeto en **bytes** tal como Python lo ve en memoria. - `1024**2` es un megabyte (1024 × 1024 bytes); dividir por esto convierte el conteo de bytes en MB para la salida impresa. - La comprensión de lista `[i for i in range(50000000)]` es **eager** — Python construye todos los 50,000,000 objetos enteros y el arreglo de punteros internos de la lista *antes* de que el nombre `massive_list` siquiera exista. Ahí es donde ocurre el pico de memoria. - `MemoryError` es lo que Python lanza cuando el sistema operativo se niega a entregar más RAM. No es un bug en tu código; es la máquina diciendo "no hay más espacio." - El `try/except` está aquí solo para que puedas observar el modo de fallo de forma limpia en lugar de colgar toda la sesión de Python.

En una laptop estándar, esta lista podría ocupar más de 400 MB de RAM solo para la estructura misma — sin contar los enteros reales. Escala esto a 500 millones, o intenta cargar un archivo de registro de 10GB, y tu sistema se colapsa. Ese es el muro de ‘Memoria Llena’.

En lugar de una lista (la comida terminada), necesitamos una receta — un generador. Un generador no retiene los datos. Retiene las instrucciones para generar los datos, una pieza a la vez.

Conoce la palabra clave ‘yield’: el botón de pausa de tu código

En una función normal, usas return. Cuando Python llega a return, la función termina: entrega el resultado y limpia su memoria. yield funciona de manera diferente. Piensa en ello como un botón de ‘pausa’.

Cuando una función usa yield, se convierte en un generador. Llámala, y nada se ejecutará todavía. Solo se queda en espera. Pide un valor, y se ejecuta hasta que llega a yield, te entrega ese único valor, y luego se queda justo ahí, esperando la siguiente petición.

La parte complicada: la función ‘recuerda’ su estado. Comparemos una función estándar con un generador.

def get_numbers_list(n):
    result = []
    for i in range(n):
        result.append(i)
    return result

def get_numbers_generator(n):
    for i in range(n):
        yield i

# The list function builds everything first
numbers = get_numbers_list(5)
print(f"List: {numbers}")

# The generator function just waits
gen = get_numbers_generator(5)
print(f"Generator object: {gen}")

# We get values one by one
print(f"First value: {next(gen)}")
print(f"Second value: {next(gen)}")
- `return result` en `get_numbers_list` construye la lista *completa* primero, y luego devuelve la lista terminada. Para cuando se asigna `numbers`, todos los valores de `n` ya viven en la memoria RAM. - `yield i` hace algo muy diferente: **pausa** la función, emite un solo valor y mantiene el marco local de la función (la variable del bucle `i`, la posición del bucle, todo) vivo en una pila de llamadas oculta. - Llamar a `get_numbers_generator(5)` no ejecuta el cuerpo en absoluto: devuelve un **objeto generador**, un pequeño iterador con estado. Eso es lo que significa `` en la salida en pantalla. - `next(gen)` reanuda la función en pausa, se ejecuta hasta el siguiente `yield` y devuelve ese valor. Llama a `next(gen)` de nuevo y se reanuda desde el *mismo punto exacto*, como si nunca te hubieras ido. - La versión de la lista ya ha hecho todo el trabajo para cuando se ejecuta el primer `print`. El generador aún no ha hecho ningún trabajo en absoluto: solo está en espera, esperando a que se le pida.

Lo que esto significa en la práctica: la función generadora solo necesita suficiente memoria para contener un elemento a la vez. La función de lista necesita suficiente memoria para todos ellos.

La expresión generadora: un superpoder en una línea

Probablemente conozcas las comprensiones de lista como [x for x in data]. Son útiles, pero “eager”: la lista completa se construye de inmediato.

Sin embargo, cambia esos corchetes [] por paréntesis () y obtendrás un generador. Eso es una expresión generadora.

import sys

# A list comprehension
list_comp = [i for i in range(1000000)]

# A generator expression
gen_exp = (i for i in range(1000000))

print(f"List size: {sys.getsizeof(list_comp)} bytes")
print(f"Generator size: {sys.getsizeof(gen_exp)} bytes")
- `[i for i in range(1000000)]` es una **comprensión de lista**: reserva una lista real de Python y la llena con los un millón de enteros antes de que se enlace el nombre `list_comp`. - `(i for i in range(1000000))` es una **expresión generadora**: misma sintaxis, diferente delimitador. Devuelve un único objeto generador pequeño que contiene solo la *receta* para producir valores, no los valores en sí. - `sys.getsizeof(gen_exp)` mide el tamaño del **objeto generador en sí**: un pequeño marco que contiene una referencia al iterador `range` y a la variable del bucle. *No* mide el millón de enteros que el generador eventualmente produciría, porque aún no los ha producido. - Por eso el tamaño del generador es de ~112 bytes sin importar si el rango es `1000000` o `10**9`: el costo de almacenamiento es independiente de cuántos elementos *se producirían*. Solo sabe *cómo* contar, no hasta dónde contaría.

En mi máquina, la lista ocupa 8,448,728 bytes. ¿El generador? 112 bytes.

Diez elementos o diez mil millones: el generador se mantiene en 112 bytes. ¿Por qué? Porque no está almacenando los números. Está almacenando la lógica de cómo contar hasta un millón.

Datos del mundo real: streaming de un CSV masivo

Apliquemos esto a un problema real de ciencia de datos. Digamos que tienes un CSV con 10 millones de filas de datos de ventas, y quieres solo las filas donde ‘price’ es mayor a $100. pandas.read_csv() carga todo el archivo en la RAM.

Un generador te permite procesar el archivo como un flujo en su lugar. El uso de RAM se mantiene constante, incluso con 100GB.

import csv

def stream_expensive_items(filename):
    with open(filename, mode='r') as f:
        # csv.DictReader is a generator! It reads one line at a time.
        reader = csv.DictReader(f)
        for row in reader:
            if float(row['price']) > 100:
                yield row

# Let's imagine 'huge_data.csv' exists
# expensive_items = stream_expensive_items('huge_data.csv')
# for item in expensive_items:
#     print(item)
- `open(filename, mode='r')` devuelve un **objeto de archivo** que es iterable línea por línea. *No* carga todo el archivo en la memoria; simplemente mantiene un descriptor de archivo del sistema operativo y un pequeño búfer de lectura. - `csv.DictReader(f)` es en sí mismo un iterador: cada llamada a `next(reader)` extrae una línea de `f`, la analiza sintácticamente y la convierte en un `dict`, para luego devolverla. La fila devuelta anteriormente se descarta, por lo que solo los datos de una fila residen en la RAM a la vez. - `with open(...) as f:` es un **gestor de contexto**: garantiza que el identificador de archivo se cierre al salir del bloque, incluso si se lanza una excepción a mitad del flujo o si quien lo llama deja de extraer datos antes de tiempo. - `yield row` convierte a `stream_expensive_items` en un generador, por lo que el bucle `for` de quien lo llama *extrae* una fila a la vez a través de toda la cadena. Nada se ejecuta hasta que el consumidor solicita el siguiente valor. - Todo el pipeline es **basado en extracción / perezoso (lazy)**: el archivo solo se lee a la velocidad a la que el bucle que lo consume lo procesa. Eso es lo que mantiene el uso de RAM constante, independientemente de si el archivo pesa 10 MB o 100 GB.

La idea es esta: abrimos el archivo, tomamos una fila, verificamos el precio. La descartamos si es barata; la pasamos si es cara. Luego, la siguiente fila. Nunca más de una fila en memoria a la vez. El resultado: puedes procesar archivos más grandes que tu RAM sin ralentizar tu computadora.

El problema: cuando los generadores no son la solución

Los generadores suenan como magia, pero tienen dos limitaciones reales.

Primero, son de un solo uso. Recorre un generador con un bucle y queda agotado. Piensa en un rollo de película: una vez que lo has revelado, no puedes reproducirlo de nuevo sin empezar desde cero y releer la fuente.

Segundo, no hay indexación. ¿Quieres el elemento 500? El generador tiene que ejecutar la lógica de los primeros 499 para llegar ahí.

gen = (i for i in range(3))

# First pass
for val in gen:
    print(val)

# Second pass - this will print NOTHING
for val in gen:
    print("This won't run!")

try:
    print(gen[0])
except TypeError as e:
    print(f"Error: {e}")
- `for val in gen` es azúcar sintáctica para "llamar `next(gen)` repetidamente hasta que se lance `StopIteration`" — cada iteración avanza el estado interno del generador un paso. - Una vez que termina el primer bucle `for`, el generador ha llegado a `StopIteration` (su `range(3)` se ejecutó hasta el final). El estado interno ahora está "agotado" — no hay botón de rebobinado en un generador. - El segundo bucle `for` llama a `next(gen)` una vez, recibe inmediatamente `StopIteration`, y sale del cuerpo del bucle sin ejecutarse nunca. No se lanza ningún error — simplemente no hace nada en silencio. - `gen[0]` lanza `TypeError` porque los objetos generador definen `__next__` (para iteración) pero **no** `__getitem__` (para indexación). El acceso aleatorio no es compatible porque llegar al índice 500 requeriría ejecutar 500 pasos de lógica — lo que derrotaría todo el propósito de ser perezoso. - Si necesitas un nuevo recorrido sobre los mismos datos, debes **reconstruir** el generador (llamar la función de nuevo, o reevaluar la expresión). No puedes "reiniciar" un objeto generador existente que ya está agotado.

Si necesitas ordenar, mezclar o acceder a filas específicas múltiples veces, quédate con una lista (o un DataFrame de Pandas). Pero para limpieza, filtrado o agregación, los generadores son difíciles de superar.

Los generadores y las listas resuelven el mismo problema de "producir una secuencia de valores" desde direcciones opuestas. Cuál encaja mejor depende de lo que el **consumidor** de los datos necesite hacer con ellos.

Usa un generador cuando:

  • La fuente es más grande que la RAM — archivos de log, CSVs grandes, cursores de base de datos, flujos de red.
  • Solo necesitas cada valor una vez: un filter, map o reduce de una sola pasada (sum(), max(), conteo, escribir filas a un archivo).
  • Quieres empezar a producir salida antes de que toda la entrada esté lista (streaming verdadero).
  • El código aguas abajo solo itera con un bucle for y nunca pide data[i].

Usa una lista (o un DataFrame de Pandas/Polars) cuando:

  • Necesitas acceso aleatorio: data[500], slicing o búsqueda por posición.
  • Necesitas múltiples pasadas sobre los mismos datos — por ejemplo, calcular una media y luego restarla de cada fila.
  • Necesitas ordenar, mezclar, agrupar o unir, todo lo cual requiere mantener el dataset completo en memoria para comparar valores entre sí.
  • El dataset cabe cómodamente en RAM, y prefieres pagar el costo de memoria una sola vez que reconstruir el generador desde la fuente cada vez que necesitas reiterar.

El compromiso fundamental en una oración: la evaluación perezosa te da memoria O(1) pero te cuesta la reiteración y la indexación — y si el código aguas abajo termina llamando list(gen) para recuperar esas habilidades, has pagado tanto la contabilidad del generador como la lista materializada completa, lo cual es lo peor de ambos mundos.

Prueba de olfato: si te encuentras escribiendo data = list(my_generator) inmediatamente después de crearlo, no querías un generador. Querías una lista.

Dos salidas de emergencia que vale la pena conocer:

  • itertools.tee(gen, n) divide un generador en n iteradores independientes — pero tiene que almacenar valores internamente, así que si las bifurcaciones avanzan a diferentes velocidades, la memoria se acerca de nuevo al tamaño de una lista.
  • itertools.islice(gen, start, stop) te permite tomar un slice sin indexación — útil para inspeccionar las primeras N filas de un flujo (list(islice(gen, 10))) sin materializar todo el contenido.

Regla práctica: si la respuesta a “¿necesito mirar algún valor más de una vez?” es , usa una lista. Si es no, usa un generador y deja que el ahorro de memoria se acumule a través de todo tu pipeline.

Resumen

Entonces, aquí está el resumen:

  1. Las listas son ‘ansiosas’: cada elemento reside en la RAM.
  2. Los generadores son ‘perezosos’: un elemento se produce solo cuando lo solicitas, lo cual utiliza mucha menos memoria.
  3. La palabra clave yield: permite que una función se pause y se reanude.
  4. El truco de los paréntesis: (x for x in data) construye un generador sobre la marcha.
  5. Sentido único: los generadores se ejecutan una vez y no admiten indexación.

La próxima vez que te topes con un MemoryError, intenta usar yield antes de actualizar tu RAM.

Comprueba tu comprensión

Las preguntas a continuación progresan desde el simple recuerdo hasta el diseño abierto, siguiendo aproximadamente la Taxonomía de Bloom.

Recordar ¿Cuál es la diferencia clave en el uso de memoria entre una lista por comprensión [x for x in data] y una expresión generadora (x for x in data)?

Comprender Con sus propias palabras, explique por qué no puede solicitar data[500] directamente a un generador, utilizando la explicación del artículo sobre cómo un generador llega a su elemento 500.

Aplicar Utilizando el patrón stream_expensive_items del artículo, ¿cambiaría el uso de memoria de esta función si el archivo CSV de entrada pasara de 10 millones de filas a 100 millones de filas? ¿Por qué sí o por qué no?

Analizar El artículo menciona que los generadores son de “un solo uso”: una vez agotados, volver a iterar no imprime nada. Analice paso a paso qué saldría mal si le pasara el mismo objeto generador a dos funciones diferentes, en las que cada una esperaría iterar sobre el conjunto de datos completo (p. ej., una calcula una suma y la siguiente calcula un promedio).

Evaluar El artículo recomienda generadores para “limpiar, filtrar o agregar datos”, pero listas y DataFrames para ordenar, mezclar o acceso repetido. Critique el diseño de un pipeline que intenta procesar un archivo de 50GB mediante una cadena de generadores de extremo a extremo, incluyendo un paso que necesita ordenar los datos por una columna: ¿qué debe cambiar en la arquitectura del pipeline en ese paso específico?

Crear Diseñe un pipeline basado en generadores para un nuevo escenario: procesar un archivo de logs del servidor de 20GB para contar cuántas solicitudes provienen de cada dirección IP única, sin cargar todo el archivo en la memoria. Bosqueje la(s) función(es) generadora(s) que escribiría y explique por qué el paso final de conteo (que necesita recordar cada IP vista hasta el momento) no anula el propósito de ahorro de memoria de los pasos de streaming anteriores.

Artículos relacionados

  • ¿Por qué se cae mi pipeline de datos? Una guía amigable — cuando tu pipeline se cae, este artículo repasa a los sospechosos habituales (memoria, deriva de esquema, cambios en la forma de los datos aguas arriba) y cómo priorizarlos.
  • Vectorización en Python: por qué es 100-1000x más rápido — una vez que tus datos están en memoria, la siguiente palanca para acelerar el proceso es reemplazar los bucles a nivel de Python con operaciones vectorizadas de NumPy/Pandas. Combina el “no cargar todo” de este artículo con el “hacer menos por fila” de ese artículo para tener el panorama completo.

Referencias y lecturas adicionales


Aplica lo que aprendiste

Resumen. Has heredado un servicio de inferencia por lotes que puntúa transacciones de clientes a partir de un CSV de 10 millones de filas. Sigue el patrón stream_expensive_items de este artículo — csv.DictReader devuelve una fila a la vez, cada fila se puntúa y se recopilan las predicciones. En el entorno de desarrollo (dev), con un conjunto de datos de prueba de 1,000 filas, devolvió predicciones correctas. Cuando se desplegó en el entorno de preproducción (staging) con el archivo real de 10M filas, se ejecutó hasta completarse pero devolvió cero predicciones — sin MemoryError, sin fallos, solo una lista vacía. El ingeniero de guardia dijo: “es como si las filas hubieran desaparecido”.

Aquí está el servicio:

import csv
import logging

logger = logging.getLogger(__name__)

def stream_rows(filename):
    with open(filename) as f:
        reader = csv.DictReader(f)
        for row in reader:
            yield row

def run_inference(filename, model):
    rows = stream_rows(filename)

    # Production monitoring: log total row count
    total = sum(1 for _ in rows)
    logger.info(f"Processing {total} rows from {filename}")

    # Score every row
    predictions = [model.predict(row) for row in rows]
    return predictions

La línea total = sum(...) se añadió durante el despliegue en staging para observabilidad — no estaba en la rama de dev.

Entregable. Escribe un informe post-mortem del incidente de 150–250 palabras que (1) nombre el error exacto de una línea, (2) explique el mecanismo — por qué sum(1 for _ in rows) causa que la comprensión de lista en la siguiente línea produzca [] — usando el lenguaje de “one-and-done” / StopIteration del artículo, y (3) proponga una solución que preserve el diseño de transmisión por secuencias (RAM plana independientemente del tamaño del archivo). Tu solución no debe ser rows = list(stream_rows(filename)) — explica por qué eso reintroduciría el pico de memoria de 400 MB+ del que advertía el artículo y anularía la ventaja de 112 bytes del generador.

Rúbrica:

  • Error identificado: total = sum(1 for _ in rows) agota el generador antes de que se ejecute el pase de inferencia.
  • Mecanismo explicado: sum(1 for _ in rows) itera el generador hasta completarlo, alcanzando StopIteration internamente. Cuando la comprensión de lista [model.predict(row) for row in rows] llama a next(rows) nuevamente, recibe StopIteration inmediatamente — el cuerpo del bucle nunca se ejecuta, por lo que predictions permanece como []. Hace referencia al comportamiento del artículo de “el segundo bucle for no hace nada en silencio”.
  • Dev-vs-staging justificado: La línea de monitoreo se añadió en staging, no en dev — por lo que el generador de dev nunca se agotó previamente y la inferencia funcionó normalmente con el conjunto de datos de prueba de 1,000 filas.
  • La solución preserva la transmisión por secuencias: Propone una solución de un solo pase (p. ej., contar y predecir en un solo bucle for, o llamar a stream_rows(filename) una segunda vez para el pase de inferencia) — no list(stream_rows(...)).
  • Rechaza el mal olor de list(): Nota explícitamente que list(stream_rows(filename)) materializaría las 10M de filas en la RAM — el mismo escenario de MemoryError que el ejemplo de 50 millones de enteros del artículo (~400 MB+ solo para la estructura de la lista) — y que el objetivo principal del generador era su huella constante de ~112 bytes independientemente del tamaño del conjunto de datos.

Esta traducción fue generada automáticamente y puede contener errores. Si el idioma inglés es tu preferencia, puedes leer el artículo original en inglés .

¿Buscas otra cosa?

Busca en todos los artículos por título, resumen o tema.