Cómo detectar memory leaks en Python y en qué línea exacta empiezan

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 en gc.garbage y 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

COMPARTE ESTE ARTÍCULO

COMPARTIR EN FACEBOOK
COMPARTIR EN TWITTER
COMPARTIR EN LINKEDIN
COMPARTIR EN WHATSAPP