Cuando abres un archivo con f = open("datos.txt"), Python te devuelve un objeto de archivo y el sistema operativo reserva un descriptor de archivo —un recurso limitado del kernel. Si tu código lanza una excepción antes de llegar a f.close(), ese descriptor queda abierto indefinidamente. En un script de uso único esto es molesto; en un servidor que procesa miles de peticiones, es una bomba de tiempo: el proceso eventualmente falla con OSError: [Errno 24] Too many open files.

El bloque with resuelve esto con lo que se llama un context manager: un protocolo que garantiza la ejecución de código de limpieza siempre, sin importar cómo salgas del bloque —ya sea normalmente, con un return, o con una excepción.

El mecanismo concreto son dos métodos especiales. Cuando Python encuentra with open("datos.txt") as f:, llama a open().__enter__(), que devuelve el objeto de archivo y lo asigna a f. Cuando el bloque termina —por cualquier razón— Python llama automáticamente a f.__exit__(exc_type, exc_val, exc_tb), que internamente ejecuta f.close(). Si hubo excepción, los argumentos la describen; si todo fue bien, los tres son None. En ambos casos, close() ocurre.

Usa with siempre que abras un archivo. La pregunta no es “¿es necesario aquí?”, sino “¿por qué arriesgarme a no cerrarlo?”. La única excepción razonable es en el REPL explorando datos a mano.

import sys

def contar_palabras(ruta: str) -> int:
    total = 0
    with open(ruta, encoding="utf-8") as f:
        for linea in f:
            total += len(linea.split())
    return total


def copiar_filtrando_vacias(origen: str, destino: str) -> int:
    """Copia líneas no vacías de origen a destino.
    Devuelve cuántas líneas se escribieron.
    """
    escritas = 0

    # Dos archivos en un solo with: ambos se cierran aunque falle cualquiera.
    with open(origen, encoding="utf-8") as src, open(destino, "w", encoding="utf-8") as dst:
        for linea in src:
            if linea.strip():          # descarta líneas vacías o solo espacios
                dst.write(linea)
                escritas += 1

    return escritas


def demo_cierre_ante_excepcion(ruta: str) -> None:
    try:
        with open(ruta, encoding="utf-8") as f:
            print(f"¿Cerrado dentro del with? {f.closed}")   # False
            raise ValueError("algo salió mal a mitad de lectura")
    except ValueError:
        pass  # la excepción se propagó, pero __exit__ ya se ejecutó

    print(f"¿Cerrado después del with? {f.closed}")   # True — garantizado


if __name__ == "__main__":
    import tempfile, os

    # Crear archivo temporal para el demo
    with tempfile.NamedTemporaryFile(mode="w", suffix=".txt",
                                     delete=False, encoding="utf-8") as tmp:
        tmp.write("hola mundo\n\nesta línea tiene texto\n   \nfin\n")
        ruta_tmp = tmp.name

    print("Palabras:", contar_palabras(ruta_tmp))

    ruta_dest = ruta_tmp + ".out"
    n = copiar_filtrando_vacias(ruta_tmp, ruta_dest)
    print(f"Líneas copiadas: {n}")

    demo_cierre_ante_excepcion(ruta_tmp)

    os.unlink(ruta_tmp)
    os.unlink(ruta_dest)

Qué está pasando en el código

contar_palabras es el patrón más básico: abres, iteras línea a línea (eficiente en memoria porque no carga el archivo entero), y el with cierra el archivo al salir. Nada especial, pero ese “nada especial” es exactamente el punto: no tienes que recordar cerrar.

copiar_filtrando_vacias muestra la sintaxis de múltiples context managers en un solo with: with open(a) as f1, open(b) as f2:. Esto es equivalente a anidar dos bloques with, pero más limpio. Si open(destino) falla —porque el directorio no existe, por ejemplo— Python igual cierra src porque su __exit__ ya fue registrado. El orden de cierre es inverso al de apertura: dst primero, luego src.

demo_cierre_ante_excepcion hace explícito el contrato: incluso cuando lanzas una excepción dentro del bloque, f.closed es True en cuanto sales. El atributo .closed está ahí precisamente para que puedas verificarlo; en código de producción no necesitas comprobarlo, pero es útil para entender qué garantiza el protocolo.

Fíjate en que f sigue siendo accesible fuera del with —Python no destruye la variable, solo cierra el descriptor. Intentar leer de f después del bloque lanzará ValueError: I/O operation on closed file, que es el comportamiento correcto: el recurso liberado ya no está disponible.

Errores que debes conocer

Error: Abrir el archivo fuera del with y confiar en que el GC lo cerrará eventualmente.

# ❌ Wrong
f = open("log.txt", "w")
f.write("entrada\n")
# si aquí lanza excepción, f nunca se cierra hasta que el GC actúe (o nunca)

# ✅ Right
with open("log.txt", "w") as f:
    f.write("entrada\n")

CPython cierra el archivo cuando el contador de referencias llega a cero, pero PyPy u otras implementaciones no garantizan cuándo ocurre eso; el with hace el cierre determinístico en cualquier implementación.

Error: Usar with pero leer el contenido después de salir del bloque.

# ❌ Wrong
with open("datos.txt") as f:
    pass  # olvidamos leer aquí
contenido = f.read()  # ValueError: I/O operation on closed file

# ✅ Right
with open("datos.txt") as f:
    contenido = f.read()
# ahora contenido está disponible fuera del bloque

El archivo se cierra al salir del with; todo el trabajo con el objeto f debe ocurrir dentro.

Error: Encadenar open() sin with al pasar archivos a funciones que pueden lanzar.

# ❌ Wrong
procesar(open("a.txt"), open("b.txt"))   # si open("b.txt") falla, "a.txt" queda abierto

# ✅ Right
with open("a.txt") as fa, open("b.txt") as fb:
    procesar(fa, fb)

La coma dentro del with registra cada context manager de forma independiente, así el cierre de fa está garantizado aunque open("b.txt") explote.

82