DevHireLab
Tutorials
BootcampProblemsCode SimulatorAI InterviewSoonContact
DevHireLab
Tutorials
BootcampProblemsCode SimulatorAI InterviewSoonContact
Getting Started
01
What is Python and Why Learn It in 2026
02
Installing Python on Windows, Mac, and Linux
03
Setting up VS Code / PyCharm for Python
04
Understanding the Python Interpreter and REPL
05
Writing and Running Your First Python Program
06
Python Syntax and Indentation Rules
Variables and Data Types
01
Variables and Naming Conventions in Python
02
Numbers in Python (int, float, complex)
03
Strings and String Methods in Python
04
Booleans in Python
05
Type Conversion / Casting in Python
06
Understanding the None Type in Python
Operators
01
Arithmetic Operators in Python
02
Comparison Operators in Python
03
Logical Operators in Python
04
Assignment Operators in Python
05
Bitwise Operators in Python
06
Identity (is) and Membership (in) Operators
07
Walrus Operator (:=) in Python
Input, Output & Strings
01
input() and print() Functions in Python
02
f-strings and String Formatting in Python
03
String Slicing and Indexing in Python
04
Common String Methods (split, join, strip, replace)
05
Escape Characters in Python Strings
Control Flow
01
if, elif, else Statements in Python
02
Nested Conditionals in Python
03
for Loops in Python
04
while Loops in Python
05
break, continue, and pass Statements
06
Loop with else Clause in Python
07
match-case (Structural Pattern Matching)
Data Structures
01
Python Lists: Creation, Indexing, Slicing, Methods
02
Python Tuples and Immutability
03
Python Sets and Set Operations
04
Python Dictionaries: Keys, Values, and Methods
05
List Comprehensions in Python
06
Dictionary & Set Comprehensions in Python
07
Working with Nested Lists and Dictionaries
Functions
01
Defining and Calling Functions in Python
02
Function Arguments: Positional, Keyword, Default
03
*args and **kwargs in Python
04
Return Statements in Python Functions
05
Lambda Functions in Python
06
Recursion in Python
07
Variable Scope: Local, Global, Nonlocal
08
Docstrings and Function Annotations
Functional Programming Basics
01
map(), filter(), and reduce() in Python
02
Closures in Python Explained
03
Python Decorators: Basic to Advanced
04
Generators and yield in Python
Object-Oriented Programming (OOP)
01
Classes and Objects in Python
02
The init Constructor in Python
03
Instance vs Class Variables in Python
04
Inheritance in Python (Single, Multiple, Multilevel)
05
Polymorphism in Python
06
Encapsulation in Python
07
Abstraction in Python with the abc Module
08
Magic/Dunder Methods in Python
09
Static and Class Methods in Python
10
The @property Decorator in Python
11
Python Dataclasses Explained
Error Handling
01
try, except, finally in Python
02
Handling Multiple Exceptions in Python
03
Raising Custom Exceptions in Python
04
Understanding Python's Exception Hierarchy
05
Assertions in Python
File Handling
01
Reading and Writing Text Files in Python
02
Working with the with Statement in Python
03
Working with CSV Files in Python
04
Working with JSON Files in Python
05
Working with Directories (os, pathlib) in Python
Modules and Packages
01
Importing Built-in Modules in Python
02
Creating Your Own Modules in Python
03
Understanding Packages and init.py
04
Python Standard Library Overview
05
Installing Packages with pip
Advanced Core Concepts
01
Iterators and Iterables in Python
02
Context Managers in Python (with, contextlib)
03
Multithreading Basics in Python
04
Multiprocessing Basics in Python
05
Asyncio Basics: async/await in Python
06
Memory Management and Garbage Collection in Python
07
Type Hints in Python (typing module)

Working with the with Statement in Python

You've used the python with statement constantly throughout this series — with open(...) as f: — without necessarily knowing what makes it work. python context managers are the mechanism behind it, and this article covers the full picture: the __enter__/__exit__ protocol, the simpler generator-based shortcut, real-world uses beyond files, and how to manage multiple resources cleanly.

Introduction: The Problem with Solves

The fragile pattern it replaces

Before with, safely pairing setup and cleanup code meant something like this:

f = open("data.txt")
try:
    c f.read()
finally:
    f.close()

This works, but it's fragile in a specific way: it relies on you, the developer, remembering to write the try/finally correctly, every single time, for every resource that needs cleanup. Miss the finally, or put the wrong code inside it, and a resource can leak — a file left open, a lock never released — especially if an exception occurs somewhere unexpected.

The core guarantee

with formalizes this pattern into something Python enforces automatically. When you write with some_object as x:, Python calls some_object.__enter__() to set up the new runtime context, and — critically — calls some_object.__exit__() when the block ends, no matter how it ends: normally, via an early return, or because an exception was raised partway through.

with open("data.txt") as f:
    c f.read()
# f.close() is guaranteed to have been called here, regardless of what happened above
A familiar example, generalized

You've typed with open(...) as f: throughout this series without necessarily thinking about the mechanism behind it. This article's core point: that same pattern generalizes far beyond files — to database connections, locks, temporary state changes, timers, and much more, all built on exactly the same underlying protocol.

The Protocol Behind with: enter and exit

How the as variable gets its value

If you provide a target variable with as, its value comes from whatever __enter__() returns:

with open("data.txt") as f:
    # f is exactly what open(...).__enter__() returned

__exit__(), meanwhile, receives details about any exception that occurred inside the block — its type, its value, and its traceback — as three separate arguments, letting __exit__() inspect (and potentially respond to) whatever went wrong.

Writing a first class-based context manager
class Timer:
    def __enter__(self):
        import time
        self.start = time.time()
        return self   # this becomes the 'as' value

    def __exit__(self, exc_type, exc_value, traceback):
        import time
        elapsed = time.time() - self.start
        print(f"Elapsed: {elapsed:.4f} seconds")
        return False   # don't suppress any exception

with Timer() as t:
    total = sum(range(1_000_000))

print(total)
# Elapsed: 0.0234 seconds
# 499999500000

__enter__() runs first, recording the start time and returning self (which becomes t). The code inside the with block runs next. __exit__() runs automatically once the block finishes — printing the elapsed time — regardless of whether the block completed normally or raised an exception along the way.

The suppression mechanism

__exit__()'s return value has special significance: if it returns something truthy, any exception that occurred inside the block is suppressed — treated as handled, and not propagated any further. Returning False (or None, which is the default if you don't return anything) lets the exception propagate normally, exactly as if the with block weren't there at all.

class SuppressValueError:
    def __enter__(self):
        return self

    def __exit__(self, exc_type, exc_value, traceback):
        if exc_type is ValueError:
            print(f"Suppressing: {exc_value}")
            return True   # suppress this specific exception
        return False       # let anything else propagate normally

with SuppressValueError():
    raise ValueError("This gets caught")
    print("This line never runs")

print("Program continues normally")
# Suppressing: This gets caught
# Program continues normally

Suppressing exceptions should be rare and deliberate. Silently swallowing errors inside __exit__() without a genuinely good, specific reason creates exactly the same category of problem covered in the earlier exception-handling articles — real bugs disappearing silently, rather than surfacing where they can actually be diagnosed and fixed.

The Easier Path: contextlib.contextmanager

Why a full class often feels like overkill

Writing an entire class with two separate dunder methods for simple, straightforward setup/teardown logic is genuinely more boilerplate than the task usually warrants. Python's contextlib.contextmanager decorator offers a considerably more concise alternative.

The generator-based shortcut
from contextlib import contextmanager
import time

@contextmanager
def timer():
    start = time.time()
    yield              # everything before this is __enter__; everything after is __exit__
    elapsed = time.time() - start
    print(f"Elapsed: {elapsed:.4f} seconds")

with timer():
    total = sum(range(1_000_000))
# Elapsed: 0.0231 seconds

Everything before yield runs as the setup logic (equivalent to __enter__); everything after yield runs as the cleanup logic (equivalent to __exit__) — all without writing a single dunder method, or a class at all. This is precisely the same generator mechanics covered in the earlier generators article, applied specifically to context management.

A practical example with try/finally around yield

To guarantee cleanup runs even if the code inside the with block raises an exception, wrap the yield in try/finally:

from contextlib import contextmanager
import tempfile
import shutil

@contextmanager
def temporary_directory():
    path = tempfile.mkdtemp()
    try:
        yield path   # this becomes the 'as' value
    finally:
        shutil.rmtree(path)   # guaranteed cleanup, even if the block raises

with temporary_directory() as tmp_dir:
    print(f"Working in {tmp_dir}")
    # do work inside tmp_dir...
# tmp_dir is automatically removed here, whether or not an error occurred above

Whatever value is passed to yield becomes the as value inside the with block — here, path becomes tmp_dir. The try/finally around yield ensures shutil.rmtree(path) runs regardless of what happens inside the with block, mirroring exactly the guarantee __exit__() provides in the class-based version.

Real-World Uses Beyond Files

Database transactions
from contextlib import contextmanager

@contextmanager
def transaction(connection):
    try:
        yield connection
        connection.commit()      # only runs if the block completed without error
    except Exception:
        connection.rollback()    # runs if anything inside the block raised
        raise

with transaction(db_connection):
    db_connection.execute("UPDATE accounts SET balance = balance - 100 WHERE id = 1")
    db_connection.execute("UPDATE accounts SET balance = balance + 100 WHERE id = 2")

This is a genuinely common, practical pattern: if both database operations succeed, the transaction commits; if either one raises an exception, the transaction rolls back instead, and the original exception is re-raised (via the bare raise) so the caller still sees that something went wrong.

Temporarily changing state and reliably restoring it

Context managers are an excellent fit for any "set something, do work, then restore it" pattern — working directories, environment variables, logging verbosity:

import os
from contextlib import contextmanager

@contextmanager
def change_directory(path):
    original = os.getcwd()
    os.chdir(path)
    try:
        yield
    finally:
        os.chdir(original)   # guaranteed restoration, even if an error occurs

with change_directory("/tmp"):
    print(os.getcwd())   # /tmp
print(os.getcwd())   # back to the original directory, automatically

Without this pattern, temporarily changing global or shared state and reliably restoring it afterward — especially in the presence of a possible exception — requires exactly the same careful try/finally discipline that with exists to make automatic and hard to forget.

Standard-library helpers worth knowing

contextlib.suppress() — covered in the earlier multiple exceptions article — offers a clean, explicit way to ignore specific, expected exceptions:

from contextlib import suppress

with suppress(FileNotFoundError):
    os.remove("maybe_exists.txt")

contextlib.closing() — for objects that have a .close() method but don't natively support with themselves:

from contextlib import closing
from urllib.request import urlopen

with closing(urlopen("https://example.com")) as page:
    c page.read()
# page.close() is called automatically, even though urlopen()'s result
# doesn't implement __enter__/__exit__ on its own

closing() wraps any object with a .close() method, giving it with-compatible behavior — genuinely useful for older or third-party objects that predate, or simply never implemented, the full context manager protocol themselves.

Managing Multiple Resources: Modern Syntax and ExitStack

Parenthesized syntax for several context managers at once

Since Python 3.10, you can cleanly open several context managers in a single with statement using parentheses, spanning multiple lines readably:

with (
    open("input.txt") as infile,
    open("output.txt", "w") as outfile,
):
    for line in infile:
        outfile.write(line.upper())

Both files are correctly closed when the block exits, regardless of how it ends — this parenthesized form is functionally equivalent to nesting two separate with statements, but reads more cleanly, especially once you're managing more than two resources at once. (Before 3.10, the equivalent required either nested with statements or a single line joined with commas, without the parentheses — the parenthesized form is purely a readability improvement introduced later.)

ExitStack for a dynamic number of resources

Sometimes you don't know in advance how many context managers you'll need to open — say, a variable-length list of files, or resources entered conditionally based on runtime logic. contextlib.ExitStack handles exactly this case:

from contextlib import ExitStack

filenames = ["a.txt", "b.txt", "c.txt"]

with ExitStack() as stack:
    files = [stack.enter_context(open(name)) for name in filenames]
    for file in files:
        print(file.read())
# every file is correctly closed here, regardless of how many there were

stack.enter_context(...) registers each context manager with the stack, and when the ExitStack itself exits, every registered context is cleaned up automatically — in LIFO order (last registered, first cleaned up), mirroring how nested with statements would unwind if you'd written them explicitly. This is genuinely valuable when the exact number, or even the presence, of certain resources isn't known until runtime — something a fixed, hardcoded with (a, b, c): statement simply can't express.

Quick closing guidance
  • Reach for the @contextmanager decorator first for simple, straightforward setup/teardown cases — it's more concise, and covers the overwhelming majority of everyday context manager needs without any class boilerplate.

  • Use a full class-based context manager when you need to maintain meaningful state across multiple methods, or need fine-grained control over exception handling and suppression logic that goes beyond what a simple try/finally around a yield comfortably expresses.

  • A brief note on async contexts: if you're working with asyncio (covered in a later article in this series), async with is the asynchronous equivalent of everything covered in this article, built on __aenter__/__aexit__ instead of __enter__/__exit__ — the same underlying philosophy, adapted for coroutine-based resources like async database connections or network sessions.

PREVIOUSNEXT LESSON