Python & Data Science

¿Por qué falla mi pipeline de datos? Una guía amigable para el perfilado de memoria en Python

1. El misterio de la RAM que desaparece

Pasar tres horas construyendo un pipeline de datos, ¿solo para verlo fallar al 99%? Lo pruebas con una pequeña muestra de 1,000 filas y funciona bien. Sin embargo, cuando le pasas el dataset completo — tu computadora empieza a ralentizarse, el cursor se congela y luego la terminal escupe un MemoryError. O tu kernel de Jupyter simplemente muere sin decir una palabra.

Parece una traición. La reacción natural es “solo necesito una computadora más potente”. Pero más RAM suele ser un parche. El código ineficiente consumirá 32GB con la misma rapidez que consumió 8GB. El objetivo no es tener más espacio; es ver exactamente a dónde se va tu memoria.

Consideremos un escenario típico. Cargamos un CSV que ocupa aproximadamente 500MB en disco. Podrías pensar que eso consume 500MB de RAM. No exactamente.

import pandas as pd
import numpy as np
import os

# Let's create a dummy CSV file of about 500MB
def create_huge_file(filename="large_data.csv"):
    df = pd.DataFrame({
        'id': range(10000000),
        'val1': np.random.randn(10000000),
        'val2': np.random.randn(10000000)
    })
    df.to_csv(filename, index=False)
    print(f"File {filename} created.")

# This is where the trouble starts
def load_and_process(filename):
    print("Loading data...")
    # Pandas often uses 3x to 10x the memory of the raw file size
    df = pd.read_csv(filename)
    # Creating a copy for 'processing' doubles the usage
    df2 = df.copy() 
    return df2.sum()

# create_huge_file() # Uncomment to run locally
# load_and_process("large_data.csv")
  • range(10000000) — El range de Python es una secuencia perezosa (lazy). Genera enteros bajo demanda, por lo que crear un rango de diez millones casi no cuesta nada en memoria. Pandas luego lo consume de inmediato al construir el DataFrame.
  • np.random.randn(10000000) — Esto asigna de inmediato un bloque contiguo de memoria NumPy para 10 millones de valores float64 (~80 MB cada uno). Dos columnas significan ~160 MB de datos de matriz en bruto, pero el envoltorio del DataFrame y la maquinaria de tipos elevan la huella en RAM.
  • df.to_csv(filename, index=False) — Serializar el DataFrame a CSV es un formato de texto; cada flotante se convierte en una cadena de caracteres ASCII, lo que puede inflar temporalmente la memoria durante la escritura antes de que el archivo llegue al disco.
  • pd.read_csv(filename) — Al leer el CSV de vuelta, Pandas debe analizar el texto, inferir los dtypes de las columnas y construir objetos DataFrame. La representación en memoria resultante suele ser de 3 a 10× el tamaño del archivo en disco porque la sobrecarga de los objetos de Python, la promoción de dtype y los búferes internos se acumulan.
  • df2 = df.copy().copy() crea una copia profunda completa de todo el DataFrame. En este momento, tanto df como df2 existen simultáneamente en RAM, duplicando la huella de memoria. Esta es la clásica trampa del “Dirty Bowl” que describe el artículo.
  • df2.sum() — Devuelve una pequeña Series con las sumas por columna. El objeto intermedio costoso (df2) sigue vivo en el ámbito de quien lo llama, por lo que aún no se libera nada.

Ejecuta eso, y el archivo de 500MB puede inflarse a 2GB o 3GB en memoria. Los objetos de Python son “pesados”: llevan consigo información adicional. Si no sabes cómo rastrear esto, solo estás adivinando.

2. Piensa en la memoria como un mostrador de cocina

Para entender la memoria sin la jerga informática, imagina tu RAM como un mostrador de cocina. Tu disco duro es la despensa.

Cuando quieres cocinar (procesar datos), sacas los ingredientes de la despensa y los pones en el mostrador. Un mostrador enorme te permite colocar todo a la vez. La mayoría de nosotros trabajamos con menos.

Las variables intermedias —esas pequeñas variables df2 o temp_list que creamos— son como tazones sucios que olvidaste lavar. Los usaste para un paso. Ahora solo están ahí en el mostrador, ocupando espacio. Si sigues tomando tazones nuevos sin limpiar los viejos, te quedarás sin espacio para cortar tus verduras.

El pico de memoria es el momento en que tu mostrador está más abarrotado. No importa si limpias después de la comida. Si en algún momento el mostrador estuvo tan lleno que no cabía ni una sola cebolla más, todo el proceso se detiene. Por eso tu pipeline falla.

3. El método ‘Snapshot’: viendo el panorama completo

La forma más fácil de empezar a limpiar tu cocina es encontrar qué paso causa el mayor desastre. memory_profiler ayuda con esto. Recorre tu código línea por línea y muestra cuánta memoria añade cada línea al montón.

En Jupyter, usarás el comando %mprun. Instálalo primero: pip install memory_profiler.

# In a real scenario, you'd put your function in a separate .py file
# For this example, imagine we are profiling this function:

# @profile # This decorator tells the profiler which function to watch
def process_data_inefficiently():
    import pandas as pd
    import numpy as np
    
    # Line 1: Load data
    a = pd.DataFrame(np.random.randn(1000000, 10))
    
    # Line 2: Make a copy (The 'Dirty Bowl' trap)
    b = a.copy()
    
    # Line 3: Do a calculation
    c = b.describe()
    
    return c
  • @profile — Este es el decorador de memory_profiler. Cuando lo colocas sobre una función, el profiler instrumenta cada línea dentro de esa función, midiendo la memoria antes y después de que se ejecute cada línea. La diferencia entre esas dos mediciones es la columna Incremento en la salida. Nota: %mprun en Jupyter requiere que la función objetivo resida en un archivo .py real, no en una celda del notebook, porque recarga el módulo desde el disco con el decorador inyectado.
  • np.random.randn(1000000, 10) — Asigna de forma anticipada un arreglo de 1,000,000 × 10 de valores float64. Eso son 80 millones de bytes (~76 MB) de datos crudos del arreglo, antes de que Pandas lo envuelva.
  • pd.DataFrame(np.random.randn(1000000, 10)) — Envolver el arreglo de NumPy en un DataFrame añade sobrecarga de objetos: objetos índice, etiquetas de columna, búferes internos. La columna Increment mostrará aproximadamente 76 MiB para esta línea.
  • b = a.copy() — Crea una copia profunda completa del DataFrame. La columna Increment mostrará otro pico de ~76 MiB aquí, porque tanto a como b ahora coexisten en la RAM. Esta es la línea que el profiler marcará como el “Tazón sucio” — duplicando la memoria sin razón.
  • b.describe() — Calcula estadísticas descriptivas (media, desviación estándar, mínimo, máximo, cuartiles). El resultado (c) es un pequeño DataFrame de 4 filas × 10 columnas, por lo que el incremento es pequeño, pero a y b siguen activos, por lo que el pico de memoria se mantiene alto.
  • return c — Solo c (diminuto) escapa del ámbito de la función. Pero a y b solo se liberan después de que la función retorna, por lo que el pico de memoria ya se alcanzó durante b.describe().

Ejecuta el profiler y obtendrás una tabla. La columna más importante es Incremento.

  • Un incremento de 500 MiB significa que esa línea acaba de consumir medio gigabyte de RAM.
  • Un incremento de 0 MiB significa que no utilizó memoria extra.

Así que si detectas un enorme incremento en una línea que solo copia una variable, ya encontraste al culpable. Estás desperdiciando espacio al mantener dos copias de los mismos datos en tu “mesada”.

4. El profiler ‘Fil’: encontrando el pico

A veces memory_profiler es demasiado lento, o no detecta el pico porque el aumento de memoria ocurre dentro de una biblioteca como NumPy o Pandas. Fil resuelve esto. Está diseñado para científicos de datos.

Fil no rastrea cada asignación: rastrea el Uso máximo de memoria. Genera un gráfico de flama, un diagrama que muestra exactamente qué función fue la responsable cuando la memoria alcanzó su punto más alto.

Para usarlo, ejecuta tu script así en tu terminal: fil-profile run my_script.py

Fil abre una ventana del navegador con el gráfico. Busca los bloques más anchos en la parte inferior. Un bloque ancho etiquetado como read_csv significa que tus datos son demasiado grandes para cargarlos de una sola vez. Un bloque ancho concat significa que estás creando demasiadas copias temporales durante una combinación.

Fil también detecta la memoria utilizada por las extensiones de C. Pandas está escrito en C por debajo, por lo que las herramientas estándar de Python a veces pasan por alto lo que asigna. Fil no se lo pierde.

¿Qué herramienta de memoria deberías usar? Elige según la tarea, no por costumbre.

HerramientaLo que haceIdeal paraDónde se queda corto
sys.getsizeof(obj)Informa el tamaño en bytes de un solo objeto de Python. Sin configuración, biblioteca estándar.Una revisión rápida de 5 segundos en una variable: “¿Esta lista es de 10 KB o 10 MB?”Solo mide la sobrecarga del propio objeto, no lo que referencia internamente. Una lista de 10 millones de referencias al mismo arreglo se reporta como diminuta. Inútil para rastrear fugas a través de los límites de las funciones.
tracemallocRastreador de asignaciones de la biblioteca estándar. Toma instantáneas de todas las asignaciones activas, con atribución de archivo/línea, y calcula las diferencias entre instantáneas.Cuando necesitas ver qué línea asignó más memoria entre dos puntos de control — y no quieres instalar paquetes de terceros.Ralentiza tu programa (interceptar cada asignación tiene una sobrecarga real). No “ve a través” de las interioridades de las extensiones de C (búferes de C de NumPy y Pandas) tan limpiamente como lo hace Fil.
memory_profiler (%mprun)Incrementos de memoria línea por línea por función, a través del decorador @profile. Tabla limpia con una columna de “Incremento”.Cuando ya sabes qué función es el problema y quieres profundizar en qué línea específica es la culpable.Ralentiza la ejecución significativamente (sondea la memoria en cada línea). Pierde picos que aparecen y se liberan dentro de una sola línea. En Jupyter, %mprun necesita que la función objetivo esté en un archivo .py, no en una celda del notebook.
FilGenera perfiles de la memoria pico y produce un gráfico de flama que muestra la pila de llamadas en el momento de uso máximo.Cuando todo el script se bloquea con MemoryError y no sabes qué función culpar — Fil te dirige directamente al pico.Requiere instalación de terceros. El resultado es un gráfico de flama basado en el navegador, no una celda del notebook. Es excesivo para scripts pequeños donde ya conoces la respuesta.

Regla general: Comienza con sys.getsizeof para una revisión de 5 segundos. Si eso no es suficiente, acude a tracemalloc (sin instalación, biblioteca estándar) para ver las diferencias de asignación por asignación. Si necesitas granularidad línea por línea en una función conocida, usa memory_profiler. Si todo el script se bloquea y no puedes saber dónde, usa Fil.

5. La parte más difícil: por qué la memoria no siempre ‘desaparece’

Esta es la parte más difícil de la gestión de memoria de aceptar: Eliminar una variable no siempre libera la memoria.

Python tiene un recolector de basura. Piénsalo como un compañero de cuarto que recoge los tazones sucios de la mesada — eventualmente. No limpia en el momento en que terminas de comer. Espera hasta que tiene ganas, o hasta que la mesada está llena.

Luego está la trampa de la ‘Copia Oculta’. En versiones anteriores de Pandas, casi cada operación creaba una copia completamente nueva de tus datos. Pandas 2.0+ usa “Copy-on-Write”, lo cual ayuda, pero la trampa no ha desaparecido por completo.

import pandas as pd
import sys

df = pd.DataFrame({'a': [1, 2, 3], 'b': [4, 5, 6]})

# This might look like you're just looking at a slice...
subset = df[['a']]

# But sometimes Pandas creates a whole new object in memory.
# 'del df' might not free memory if 'subset' is still pointing 
# to the original data hidden inside it!
  • import syssys se importa para que puedas llamar a sys.getsizeof(df) y verificar el tamaño reportado del objeto, aunque no se llama en este fragmento. Incluso si lo hiciera, getsizeof solo reportaría la sobrecarga del propio objeto Python del DataFrame, no los datos completos del arreglo NumPy subyacente a los que hace referencia — un error clásico al usar getsizeof en objetos complejos.
  • subset = df[['a'] — Esto es un slice de columna. En versiones anteriores de Pandas, df[['a']] creaba un nuevo DataFrame con su propia copia de los datos. En Pandas 2.0+ con Copy-on-Write habilitado, subset inicialmente comparte la memoria subyacente con df — pero tan pronto como se modifica df o subset, Pandas crea una copia detrás de escena (esa es la parte de “Copy-on-Write”). De cualquier manera, subset puede mantener una referencia al mismo gestor de bloques subyacente que df.
  • del dfdel elimina el enlace de nombre df del espacio de nombres. Pero si subset aún hace referencia a los datos subyacentes (directamente o a través de componentes internos compartidos), el recolector de basura ve que todavía hay una referencia activa y no liberará esa memoria. El objeto sobrevive; solo el nombre desaparece.
  • La solución práctica — No confíes en del para recuperar memoria. Si no necesitas el original después de una transformación, sobrescribe la misma variable: df = df.drop(columns=['b']) en lugar de crear df2 = df.drop(...). Cuando el df antiguo se reenlaza, y ninguna otra variable (como subset) mantiene una referencia, el recolector de basura finalmente puede reclamar el bloque antiguo.

Lo que esto significa en la práctica: no confíes en del. Evita crear la variable en primer lugar. Si no necesitas los datos originales después de una transformación, sobrescríbelos — df = df.drop(columns=['unnecessary']) en lugar de df2 = df.drop(...).

6. Tu nuevo flujo de trabajo: una lista de verificación de 3 pasos

Ahora que la intuición encaja, aquí te mostramos cómo manejar tu próximo pipeline que se cae:

  1. Paso 1: Usa %memit. Verifica el uso total de memoria de tu función. ¿Es realmente más alto de lo que esperabas? (por ejemplo, “Espera, ¿por qué este archivo de 100MB está usando 2GB de RAM?”)
  2. Paso 2: Usa Fil. Encuentra el ‘pico’. Identifica la línea exacta de código que empuja tu memoria al límite.
  3. Paso 3: Divídelo en fragmentos. Si tus datos son demasiado grandes para la encimera, no los pongas todos en la encimera de una vez. Usa el parámetro chunksize en Pandas.

Aquí está el “Antes y Después” con chunking:

# THE OLD WAY (Crashes on large files)
def process_everything(filename):
    df = pd.read_csv(filename)
    return df['val1'].sum()

# THE NEW WAY (Uses almost zero memory regardless of file size)
def process_in_chunks(filename):
    total = 0
    # We only bring 100,000 rows onto the 'counter' at a time
    for chunk in pd.read_csv(filename, chunksize=100000):
        total += chunk['val1'].sum()
        # When the loop moves to the next chunk, the old chunk 
        # is cleared off the counter automatically!
    return total

# The result: The second function can process a 100GB file 
# on a laptop with 8GB of RAM. 
  • pd.read_csv(filename) (forma antigua) — Carga con avidez el archivo completo en un único DataFrame. Para un archivo de 100 GB, esto intentaría asignar 300–600 GB de RAM (tras la inflación de 3–10× de Pandas). Resultado: MemoryError inmediato.
  • pd.read_csv(filename, chunksize=100000) (nueva forma) — El argumento chunksize hace que read_csv devuelva un iterador perezoso (TextFileReader) en lugar de un DataFrame. Solo lee 100,000 filas a la vez, produce un fragmento, luego lee el siguiente lote en la próxima iteración. Esta es la misma filosofía de “generador” que usarías en un generador personalizado de Python — produce un elemento, deja que el consumidor lo procese, luego produce el siguiente.
  • for chunk in ... — Cada chunk es un DataFrame regular de Pandas de 100,000 filas. Cuando el cuerpo del bucle termina y pasa a la siguiente iteración, la variable chunk anterior se reasigna, reduciendo su contador de referencias a cero. El Recolector de Basura (Garbage Collector) libera entonces la memoria de ese fragmento antes de cargar el siguiente — así que solo un DataFrame de 100,000 filas está vivo a la vez.
  • total += chunk['val1'].sum() — Esta es la idea clave: sum() es una reducción conmutativa y asociativa. Sumar 1,000 fragmentos de 100,000 filas cada uno y agregar las sumas por fragmento da el mismo resultado que sumar 100,000,000 filas a la vez. El acumulador total es un único entero (o float), con un costo esencialmente nulo.
  • Por qué esto no funcionaría para ordenar — El chunking solo funciona para operaciones que pueden descomponerse en cálculos independientes por fragmento. sum(), mean(), count(), y min()/max() son todas reducciones “vergonzosamente paralelas”. Pero sort_values() requiere ver todas las filas a la vez para determinar el orden global — no puedes ordenar fragmentos de 100,000 filas de forma independiente y luego unirlos. Para eso, necesitarías una estrategia de ordenamiento externo (por ejemplo, DuckDB o Dask), no el chunking de Pandas.

Lo que cubrimos:

  • La RAM es una encimera de cocina: Tienes espacio limitado, así que no dejes tazones sucios (variables sin usar) tirados por ahí.
  • Pandas es pesado: A menudo usa mucha más memoria que el tamaño del archivo en disco.
  • Usa las herramientas correctas: memory_profiler para verificaciones línea por línea, y Fil para encontrar el punto pico de caída.
  • El chunking funciona: Si los datos no caben, procésalos en pequeños fragmentos.

La próxima vez que tu pipeline se caiga, no saques tu tarjeta de crédito para comprar más RAM. Toma un profiler y revisa qué está pasando realmente bajo el capó.

Comprueba tu comprensión

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

Recordar ¿Qué es el “Peak Memory” y por qué el artículo dice que un pipeline puede fallar incluso si el uso promedio de memoria parece estar bien?

Comprender Con tus propias palabras, explica por qué un archivo CSV de 500MB puede aumentar a 2-3GB en la RAM una vez cargado en Pandas, usando la explicación de “objetos pesados” del artículo.

Aplicar Usando el patrón de salida de memory_profiler del artículo (la columna “Increment”), si una línea que solo copia un DataFrame (df2 = df.copy()) muestra un incremento de 500 MiB, ¿qué te dice específicamente sobre dónde enfocar tu optimización?

Analizar El ejemplo de fragmentación del artículo procesa un archivo en piezas de 100,000 filas y afirma que el fragmento antiguo “se limpia del contador automáticamente”. Analiza paso a paso por qué sumar chunk['val1'].sum() a un total acumulado funciona para esta agregación específica, pero no funcionaría si la tarea fuera en cambio “ordenar todo el archivo por val1”: ¿qué propiedad de la operación hace que la fragmentación sea viable o no?

Evaluar El artículo advierte que del df podría no liberar memoria si subset todavía apunta a datos ocultos dentro de él. Critica la solución del artículo (“sobrescribe la variable en lugar de crear una nueva,” p. ej., df = df.drop(columns=[...])) como una solución completa: ¿reasignar df garantiza que se libere la memoria del DataFrame antiguo, o eso todavía depende de si algo más (como subset) mantiene una referencia a él?

Crear Diseña un plan de depuración de memoria (siguiendo la lista de verificación de 3 pasos del artículo) para un nuevo escenario: un pipeline que combina tres archivos CSV de 2GB, agrega diez columnas calculadas y falla con MemoryError en una máquina de 16GB. Detalla qué herramienta usarías primero, qué buscarías en su salida y qué solución intentarías según lo que encuentres.

Artículos relacionados

  • Generadores de Python: Cómo procesar conjuntos de datos masivos — El procesamiento por fragmentos con pd.read_csv(chunksize=...) es simplemente el generador integrado de Pandas. Aprende la mecánica subyacente de yield para que puedas construir tus propios pipelines eficientes en memoria desde cero.
  • Polars vs Pandas: Una guía de migración práctica — Si el inflado de memoria de 3–10× de Pandas es la causa raíz, el modelo de memoria Arrow basado en Rust de Polars a menudo utiliza 2–4× menos RAM para la misma carga de trabajo. Vale la pena revisarlo antes de reescribir tu pipeline en fragmentos.

Referencias y lecturas adicionales

  • Documentación de tracemalloc de Python — Referencia oficial del trazador de asignación de memoria de la biblioteca estándar, incluyendo take_snapshot(), compare() y estadísticas basadas en filtros.
  • Documentación del profilador Fil — Documentación oficial del profilador Fil, enfocada en gráficos de flama de memoria máxima para ciencia de datos y compatibilidad con extensiones de C.

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 .

«Aplica lo que aprendiste» es para suscriptores Supporter e Insider.

Suscríbete para desbloquear los ejercicios de este artículo.

Ver planes

¿Buscas otra cosa?

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