Que Python tenga recolector de basura no significa que sea inmune a los memory leaks. Significa que hay una capa de gestión automática, pero esa capa no resuelve todos los casos. Referencias circulares sin weakref, cachés que crecen sin límite, closures que retienen objetos grandes... cualquiera de estos patrones puede hacer que tu proceso consuma cada vez más RAM hasta que el OOM killer de Linux lo mate en producción.
El síntoma más común es un proceso que arranca consumiendo 200 MB y al cabo de unas horas está en 2 GB sin que haya habido un pico de carga que lo justifique. A veces es gradual y lo ves en las métricas; otras veces el proceso simplemente muere y tardas un rato en entender por qué.
En este artículo vas a ver las herramientas concretas para detectar esos leaks y llegar a la línea exacta que los causa.
¿Qué puede provocar un memory leak en Python?
El GC de Python usa conteo de referencias como mecanismo principal. Cuando el contador de referencias de un objeto llega a cero, se libera la memoria. El problema viene cuando dos objetos se referencian mutuamente: ninguno llega a cero aunque nadie más los use. Python tiene un recolector cíclico que detecta esos ciclos, pero no siempre los rompe a tiempo, y en algunos casos (objetos con __del__) directamente no puede.
Hay tres causas habituales:
- Referencias circulares sin
weakref: objeto A tiene una referencia a objeto B, y B tiene una referencia a A. Si nadie más los referencia, el GC cíclico debería recogerlos, pero si tienen__del__definido, Python los mete engc.garbagey no los libera automáticamente. - Cachés sin límite de tamaño: un diccionario que usas como caché y que nunca vacías. Cada clave nueva añade un objeto más. Con suficiente tráfico, el diccionario crece hasta llenar la RAM disponible.
- Closures que retienen objetos grandes: una función anidada que captura una variable del scope exterior. Mientras exista la función, el objeto capturado tampoco se libera, aunque ya no lo necesites.
tracemalloc: el profiler de memoria que ya viene con Python
tracemalloc está en la stdlib desde Python 3.4. No necesitas instalar nada. Te permite hacer snapshots del estado de la memoria y compararlos para ver qué ha crecido entre dos momentos.
Uso básico
import tracemalloc
tracemalloc.start()
# ... aquí va el código que quieres analizar ...
snapshot = tracemalloc.take_snapshot()
top_stats = snapshot.statistics('lineno')
for stat in top_stats[:10]:
print(stat)
Eso te da las 10 líneas que más memoria están alojando en ese momento, con el archivo y el número de línea.
Comparar dos snapshots para ver qué crece
La parte realmente útil es comparar el estado antes y después de ejecutar algo:
import tracemalloc
tracemalloc.start()
snapshot1 = tracemalloc.take_snapshot()
# Código sospechoso
for _ in range(1000):
procesar_peticion()
snapshot2 = tracemalloc.take_snapshot()
top_stats = snapshot2.compare_to(snapshot1, 'lineno')
print("=== Top 10 incrementos de memoria ===")
for stat in top_stats[:10]:
print(stat)
El resultado te muestra línea por línea cuánta memoria extra hay en snapshot2 respecto a snapshot1. Si hay un leak, aparece la línea que lo provoca.
Filtrar por módulo
Si tu proyecto tiene muchos módulos y quieres centrarte solo en el tuyo:
filters = [tracemalloc.Filter(True, "mi_modulo*")]
filtered_stats = snapshot2.compare_to(snapshot1, 'lineno', cumulative=True)
for stat in filtered_stats[:5]:
print(stat)
Ejemplo con un leak intencional
import tracemalloc
# Lista global que nunca se limpia: leak clásico
cache_global = []
def procesar(dato):
# Guardamos el dato pero nunca limpiamos la caché
cache_global.append({"dato": dato, "padding": "x" * 10000})
tracemalloc.start()
snapshot1 = tracemalloc.take_snapshot()
for i in range(500):
procesar(i)
snapshot2 = tracemalloc.take_snapshot()
for stat in snapshot2.compare_to(snapshot1, 'lineno')[:5]:
print(stat)
La salida te dirá que la línea con cache_global.append() es la que está alojando toda esa memoria extra.
memory-profiler: ver el uso de memoria línea por línea
memory-profiler es una librería externa que añade un decorador @profile a las funciones. Cuando ejecutas el script con python -m memory_profiler, te muestra el consumo de RAM línea por línea dentro de esas funciones.
pip install memory-profiler
Uso:
from memory_profiler import profile
@profile
def funcion_sospechosa():
datos = []
for i in range(10000):
datos.append("elemento_" + str(i) * 100)
return datos
funcion_sospechosa()
Ejecutas con:
python -m memory_profiler mi_script.py
Y obtienes algo así:
Line # Mem usage Increment Line Contents
================================================
5 45.3 MiB 45.3 MiB @profile
6 def funcion_sospechosa():
7 45.3 MiB 0.0 MiB datos = []
8 45.3 MiB 0.0 MiB for i in range(10000):
9 54.1 MiB 8.8 MiB datos.append("elemento_" + str(i) * 100)
10 54.1 MiB 0.0 MiB return datos
El incremento está claramente en la línea 9. Con funciones más complejas esto vale mucho.
objgraph: qué objetos hay en memoria y por qué no se liberan
objgraph te da una visión de alto nivel de qué tipos de objetos existen en la heap de Python en un momento dado. Es especialmente útil para confirmar que el tipo de objeto que sospechas es el que está creciendo.
pip install objgraph
Ver qué tipos de objetos abundan más
import objgraph
objgraph.most_common_types(limit=10)
# [('dict', 12453), ('list', 8901), ('function', 3421), ...]
Ver qué ha aparecido entre dos momentos
import objgraph
objgraph.show_growth(limit=5)
# Primera llamada establece la línea base
# ... ejecuta el código sospechoso ...
objgraph.show_growth(limit=5)
# Segunda llamada muestra solo lo que ha crecido
Ver por qué un objeto no se libera
Si sabes qué tipo de objeto está creciendo, puedes coger una instancia y ver quién la está referenciando:
import objgraph
# Obtener una instancia del tipo problemático
objetos = objgraph.by_type('MiClase')
if objetos:
objgraph.show_backrefs(objetos[0], max_depth=3)
Esto genera un grafo (requiere graphviz) que muestra la cadena de referencias que impide que el objeto se libere. Muy útil para confirmar referencias circulares.
gc.collect() y cómo gestionar las referencias circulares
El GC cíclico de Python se ejecuta automáticamente, pero puedes forzarlo con gc.collect(). También puedes preguntarle quién retiene un objeto concreto:
import gc
# Forzar recolección
gc.collect()
# Ver qué objetos no se pudieron liberar
print(gc.garbage) # Lista de objetos con __del__ en ciclos
# Ver quién referencia un objeto concreto
mi_objeto = MiClase()
referentes = gc.get_referrers(mi_objeto)
print(referentes)
Para romper referencias circulares sin que los objetos se retengan entre sí, usa weakref:
import weakref
class Nodo:
def __init__(self, valor):
self.valor = valor
self.padre = None # Referencia fuerte: puede causar ciclo
class NodoSeguro:
def __init__(self, valor):
self.valor = valor
self._padre = None
@property
def padre(self):
if self._padre is not None:
return self._padre() # Llamar al weakref para obtener el objeto
return None
@padre.setter
def padre(self, nodo):
self._padre = weakref.ref(nodo) if nodo is not None else None
Con weakref.ref(), la referencia al padre no incrementa el contador de referencias, así que cuando nadie más use el padre, se libera aunque el hijo todavía exista.
Causas habituales de leaks en Python y cómo evitarlas
Cachés sin límite de tamaño
Si usas un diccionario como caché, ponle un límite. Lo más fácil es functools.lru_cache:
from functools import lru_cache
@lru_cache(maxsize=1000)
def calcular_algo(parametro):
# ... lógica costosa ...
return resultado
El maxsize=1000 garantiza que nunca habrá más de 1000 entradas. Cuando se llega al límite, se descarta la entrada menos usada recientemente.
Callbacks registrados pero nunca eliminados
Si registras callbacks (en un bus de eventos, en un observer, en un signal de Django), asegúrate de desregistrarlos cuando ya no los necesites. Un callback que nadie llama pero que existe en una lista retiene todos los objetos que captura en su closure.
Logging de objetos completos
Pasar objetos grandes directamente al logger crea referencias temporales que pueden tardar en liberarse:
# Malo: retiene el objeto completo si el logger lo serializa tarde
logger.debug("Estado actual: %s", objeto_grande)
# Mejor: serializa ahora y pasa solo el string
logger.debug("Estado actual: %s", str(objeto_grande)[:200])
Threads que retienen frames de pila
Un thread que lanza una excepción y la captura puede retener el frame de pila, que a su vez retiene todas las variables locales de ese frame. Para evitarlo, limpia la excepción después de procesarla:
import sys
try:
operacion_riesgosa()
except Exception as e:
manejar_error(e)
# Limpiar la referencia al frame de la excepción
del e
sys.exc_clear() if hasattr(sys, 'exc_clear') else None
Estrategia de diagnóstico paso a paso
Cuando sospechas que hay un leak pero no sabes dónde, este orden funciona bien:
1. Confirmar que hay un leak real
Primero descarta que el crecimiento de memoria sea normal (por ejemplo, un caché legítimo que se llena al arrancar). Usa psutil para monitorizar el RSS del proceso:
import psutil
import os
proceso = psutil.Process(os.getpid())
def memoria_actual_mb():
return proceso.memory_info().rss / 1024 / 1024
print(f"Memoria inicial: {memoria_actual_mb():.1f} MB")
for _ in range(1000):
procesar_peticion()
print(f"Memoria tras 1000 peticiones: {memoria_actual_mb():.1f} MB")
Si la diferencia es grande y no se estabiliza, tienes un leak.
2. Aislar en qué función o endpoint crece
Si es una aplicación web, mide el RSS antes y después de llamar a cada endpoint. El que hace crecer la memoria más rápido es el candidato. Puedes hacerlo con un middleware que loguee el uso de memoria en cada petición.
3. tracemalloc antes y después del endpoint sospechoso
Con el endpoint identificado, pon el tracemalloc.take_snapshot() antes y después de varias llamadas y compara.
4. objgraph para confirmar el tipo de objeto
Si tracemalloc te dice que el problema está en cierta línea, usa objgraph.show_growth() para confirmar qué tipo de objeto está acumulándose.
5. Arreglar y verificar
Una vez aplicada la corrección, vuelve a medir el RSS con el mismo bucle de 1000 peticiones. Si ya no crece (o crece mucho menos), el fix funciona. No des por resuelto el leak hasta verlo en los números.
Para proyectos más complejos, también puedes integrar el profiler en extensiones en Rust para Python de alto rendimiento que manejan grandes volúmenes de datos, o aplicarlo al análisis numérico cuando trabajas con Python científico con NumPy y SciPy, donde los arrays pueden ocupar mucha RAM si no se liberan a tiempo.
Imagen: Pexels / Nemuel Sereti
