Gestionar varios archivos, conexiones, bloqueos, transacciones y acciones de limpieza puede producir bloques with profundamente anidados. ExitStack en Python, disponible en el módulo contextlib, permite registrar context managers y callbacks de forma dinámica. Es especialmente útil cuando la cantidad o el tipo de recursos solo se conoce durante la ejecución.
En esta guía aprenderás cómo funciona ExitStack, por qué libera recursos en orden inverso, cómo abrir una cantidad variable de archivos, registrar callbacks comunes, transferir la responsabilidad de cierre, manejar fallos parciales y probar cada ruta de salida. El tema complementa nuestros artículos sobre asyncio, tempfile, zipfile, filecmp y zoneinfo.
El problema de los recursos dinámicos
Una sentencia with normal funciona perfectamente cuando todos los recursos se conocen al escribir el código:
with open("entrada.txt") as origen, open("salida.txt", "w") as destino:
destino.write(origen.read())Sin embargo, una aplicación puede recibir una lista de rutas desde una configuración, descubrir archivos en una carpeta o decidir en tiempo de ejecución si necesita un lock, una transacción o una sesión de red. Abrir recursos dentro de un bucle exige garantizar que todos los ya adquiridos se cierren si una operación posterior falla.
Primer ejemplo con ExitStack
from contextlib import ExitStack
rutas = ["a.txt", "b.txt", "c.txt"]
with ExitStack() as stack:
archivos = [
stack.enter_context(open(ruta, encoding="utf-8"))
for ruta in rutas
]
contenidos = [archivo.read() for archivo in archivos]
print("Todos los archivos están cerrados")enter_context() entra en cada context manager y registra inmediatamente su método de salida. Cuando el stack se cierra, las salidas se ejecutan desde la más reciente hasta la más antigua. Este orden LIFO reproduce el comportamiento de varios bloques with anidados.
Cómo funciona la pila de salida
ExitStack guarda funciones de salida en una pila interna. Al entrar en un context manager añade su método __exit__(). Al registrar un callback añade la función indicada. Al finalizar, ejecuta todo en orden inverso.
La ventaja es que la estructura se construye durante la ejecución sin perder previsibilidad. La adquisición y la limpieza permanecen claramente relacionadas.
Abrir una cantidad variable de archivos
from contextlib import ExitStack
from pathlib import Path
def unir_archivos(origenes, destino):
with ExitStack() as stack:
entradas = [
stack.enter_context(Path(ruta).open(encoding="utf-8"))
for ruta in origenes
]
salida = stack.enter_context(
Path(destino).open("w", encoding="utf-8")
)
for entrada in entradas:
salida.write(entrada.read())
salida.write("\n")Si el tercer archivo no puede abrirse, los dos primeros ya están registrados y serán cerrados. Esto es más seguro que guardar handles en una lista y tratar de liberarlos manualmente después de una excepción.
Registrar limpieza con callback
No todos los recursos implementan el protocolo de context manager. El método callback() registra una función común con sus argumentos.
from contextlib import ExitStack
from pathlib import Path
with ExitStack() as stack:
temporal = Path("proceso.tmp")
temporal.write_text("datos", encoding="utf-8")
stack.callback(temporal.unlink, missing_ok=True)
procesar(temporal)
# el archivo temporal se eliminaUn callback normal no recibe automáticamente información sobre la excepción y no puede suprimirla. Debe realizar una limpieza pequeña, predecible e idealmente idempotente.
Usar push para salidas existentes
push() acepta un context manager o una función compatible con la firma de __exit__(). Resulta útil cuando un recurso ya fue abierto y solo necesitas transferir su salida.
from contextlib import ExitStack
recurso = crear_recurso()
recurso.__enter__()
with ExitStack() as stack:
stack.push(recurso)
usar_recurso(recurso)Siempre que sea posible, prefiere enter_context(), porque mantiene adquisición y registro en una única operación. Usa push() solo cuando la API separe deliberadamente ambas fases.
Transferir responsabilidad con pop_all
Una función puede necesitar abrir un grupo completo y devolverlo todavía activo únicamente si todas las adquisiciones tienen éxito. pop_all() mueve las salidas pendientes a un nuevo ExitStack.
from contextlib import ExitStack
def abrir_todos(rutas):
stack = ExitStack()
try:
archivos = [
stack.enter_context(open(ruta, encoding="utf-8"))
for ruta in rutas
]
except Exception:
stack.close()
raise
propietario = stack.pop_all()
return archivos, propietario
archivos, propietario = abrir_todos(["a.txt", "b.txt"])
try:
print([archivo.readline() for archivo in archivos])
finally:
propietario.close()Después de pop_all(), el stack original deja de ser responsable. El objeto retornado debe cerrarse explícitamente o usarse dentro de otro bloque with.
Seguridad ante adquisición parcial
Una ventaja central de ExitStack es limpiar correctamente después de un éxito parcial. Considera un proceso que abre un archivo, adquiere un lock y comienza una transacción:
from contextlib import ExitStack
with ExitStack() as stack:
archivo = stack.enter_context(open("datos.csv", encoding="utf-8"))
lock = stack.enter_context(obtener_lock("importacion"))
transaccion = stack.enter_context(base.transaccion())
importar(archivo, transaccion)Si el lock falla, el archivo se cierra. Si la transacción falla al entrar, se liberan el lock y el archivo. El código evita estados intermedios ocultos.
Distinguir errores de entrada y uso
A veces es necesario saber si el error ocurrió al adquirir el recurso o dentro del trabajo principal.
from contextlib import ExitStack
stack = ExitStack()
try:
conexion = stack.enter_context(conectar())
except OSError as error:
tratar_fallo_conexion(error)
else:
with stack:
ejecutar_tarea(conexion)Este formato evita interpretar un OSError producido por ejecutar_tarea() como si fuera un error de conexión.
Context managers opcionales
ExitStack simplifica recursos condicionales.
from contextlib import ExitStack
with ExitStack() as stack:
archivo = stack.enter_context(open("datos.txt", encoding="utf-8"))
if usar_lock:
stack.enter_context(lock_distribuido("datos"))
if usar_transaccion:
stack.enter_context(base.transaccion())
procesar(archivo)Así se evita una gran estructura de condiciones con todas las combinaciones posibles de bloques with.
Acciones compensatorias y rollback
Los callbacks pueden representar acciones compensatorias para operaciones de varias etapas.
from contextlib import ExitStack
with ExitStack() as stack:
usuario = crear_usuario()
stack.callback(eliminar_usuario, usuario.id)
perfil = crear_perfil(usuario.id)
stack.callback(eliminar_perfil, perfil.id)
confirmar_operacion()
stack.pop_all()Si una etapa falla, la limpieza se ejecuta en orden inverso. Si todo termina bien, pop_all() elimina el plan de rollback. Aun así, una transacción real es preferible cuando el sistema ofrece atomicidad.
Los errores de limpieza también importan
Las funciones de salida pueden fallar. No ignores silenciosamente errores al cerrar conexiones, vaciar buffers, revertir transacciones o borrar archivos temporales. Decide cómo deben registrarse y encadenarse con la excepción original.
Prueba escenarios donde el cuerpo falla y una o varias salidas también fallan. Esos casos muestran si la aplicación conserva la información de diagnóstico más útil.
AsyncExitStack
Los programas asíncronos disponen de AsyncExitStack.
from contextlib import AsyncExitStack
async with AsyncExitStack() as stack:
sesion = await stack.enter_async_context(crear_sesion())
respuesta = await stack.enter_async_context(sesion.get(url))
datos = await respuesta.json()AsyncExitStack admite limpieza síncrona y asíncrona, pero debes usar los métodos adecuados: enter_async_context(), push_async_exit() y push_async_callback().
Probar el orden de salida
from contextlib import contextmanager, ExitStack
eventos = []
@contextmanager
def recurso(nombre):
eventos.append(f"entra:{nombre}")
try:
yield nombre
finally:
eventos.append(f"sale:{nombre}")
with ExitStack() as stack:
stack.enter_context(recurso("A"))
stack.enter_context(recurso("B"))
assert eventos == ["entra:A", "entra:B", "sale:B", "sale:A"]Prueba también un fallo durante la segunda entrada, una excepción en el cuerpo, callbacks con argumentos, pop_all() y errores de cierre.
Errores frecuentes
- Usar
push()cuandoenter_context()sería más seguro. - Crear un ExitStack fuera de
withy olvidar cerrarlo. - Llamar
pop_all()sin transferir claramente la propiedad. - Registrar callbacks que capturan valores mutables cambiantes.
- Suponer que un callback normal puede suprimir excepciones.
- Implementar rollback manual cuando una transacción sería más fiable.
- No probar el orden inverso de limpieza.
Buenas prácticas
- Registra cada recurso inmediatamente después de adquirirlo.
- Prefiere
with ExitStack()para cierre automático. - Mantén callbacks pequeños, deterministas e idempotentes.
- Documenta la propiedad después de
pop_all(). - Separa errores de adquisición y procesamiento cuando sea necesario.
- Usa AsyncExitStack en flujos asíncronos.
- Prueba fallos en cada etapa.
Cuándo usar ExitStack
Usa ExitStack cuando la cantidad de recursos sea dinámica, algunos recursos sean opcionales, distintos mecanismos de limpieza deban coordinarse o una adquisición de varias etapas necesite rollback predecible. Para dos recursos fijos y simples, una sentencia with común suele ser más clara.
Conclusión
ExitStack en Python convierte la gestión dinámica de recursos en una pila explícita y fiable. Cierra en orden inverso, combina context managers con callbacks y permite transferir la responsabilidad cuando es necesario.
Registra la limpieza en cuanto adquieras cada recurso, mantén callbacks comprensibles y prueba todas las rutas de error. Consulta la documentación oficial de ExitStack y la referencia del protocolo de context managers para ampliar los detalles.







