Part of Python for Beginners

Python Context Managers Explained - with Statement, __enter__, __exit__ & contextlib | Tutorial #25

Sandy LaneSandy Lane

Video: Python Context Managers Explained - with Statement, __enter__, __exit__ & contextlib | Tutorial #25 by Taught by Celeste AI - AI Coding Coach

Take the quiz on the full lesson page
Test what you've read · interactive walkthrough

Python Context Managers: with, enter, exit, @contextmanager

with statement guarantees setup/teardown. Implement via __enter__/__exit__ on a class, or @contextmanager on a generator function. Used for files, locks, DB connections, timers — anywhere you need "do this, then make sure cleanup happens."

You've used with open(...) since lesson 15. Now we'll see how it works and how to write your own.

The with statement

with open("file.txt", "r") as f:
  content = f.read()
# f is closed here, even if read() raised

with calls open(), binds the result to f, runs the block, and then calls cleanup — even if an exception is raised.

The pattern: setup → use → teardown, with teardown guaranteed.

enter and exit

A context manager is any class that implements two methods:

import time

class Timer:
  def __enter__(self):
    self.start = time.time()
    print("Timer started")
    return self           # value bound to `as` variable

  def __exit__(self, exc_type, exc_val, exc_tb):
    elapsed = time.time() - self.start
    print(f"Elapsed: {elapsed:.4f}s")
    return False          # False means: don't swallow exceptions

with Timer() as t:
  total = sum(range(1_000_000))
  • __enter__ runs at the start of the with block. Whatever it returns is bound to the as variable.
  • __exit__ runs at the end (always). Receives the exception info if one was raised, otherwise three Nones.

The with Timer() as t: is roughly:

mgr = Timer()
t = mgr.__enter__()
try:
  ...   # block body
except:
  if not mgr.__exit__(*sys.exc_info()):
    raise
else:
  mgr.__exit__(None, None, None)

Suppressing exceptions

__exit__ can return True to suppress an exception:

class SafeDiv:
  def __enter__(self):
    return self

  def __exit__(self, exc_type, exc_val, exc_tb):
    if exc_type is ZeroDivisionError:
      print(f"Caught: {exc_val}")
      return True       # suppress
    return False        # propagate

with SafeDiv():
  result = 10 / 0
  print("never runs")

print("program continues")

Use sparingly — silently swallowing exceptions hides bugs. The right time is when you've genuinely handled the failure (logged, defaulted, etc.) and want to continue.

@contextmanager: generator-based

Writing a class is verbose. contextlib.contextmanager lets you write context managers as generators:

from contextlib import contextmanager
import time

@contextmanager
def timer(label):
  start = time.time()
  print(f"{label}: started")
  try:
    yield start
  finally:
    elapsed = time.time() - start
    print(f"{label}: {elapsed:.4f}s")

with timer("Loop") as t:
  total = sum(range(500_000))

The decorator turns the generator into a context manager:

  • Code before yield is __enter__.
  • The yielded value is what as gets bound to.
  • Code after yield is __exit__.

Wrap in try/finally to guarantee cleanup even if the body raises.

Change directory: a worked example

import os
from contextlib import contextmanager

@contextmanager
def change_dir(path):
  old = os.getcwd()
  os.chdir(path)
  try:
    yield
  finally:
    os.chdir(old)

with change_dir("/tmp"):
  print(os.getcwd())    # /tmp
  # do stuff in /tmp

print(os.getcwd())      # back to original

This is the use case for context managers: temporarily change global state, ensure restoration.

Built-in context managers

A few you'll see:

# File I/O
with open("file.txt") as f:
  ...

# Threading lock
import threading
lock = threading.Lock()
with lock:
  # only one thread here at a time

# Suppressing specific exceptions
from contextlib import suppress
with suppress(FileNotFoundError):
  os.remove("maybe_exists.txt")

# Redirecting stdout
from contextlib import redirect_stdout
with open("log.txt", "w") as f:
  with redirect_stdout(f):
    print("This goes to log.txt")

# Decimal precision
from decimal import localcontext
with localcontext() as ctx:
  ctx.prec = 50
  # extra precision in this block

ExitStack: composing context managers

from contextlib import ExitStack

with ExitStack() as stack:
  files = [stack.enter_context(open(name)) for name in filenames]
  # all files closed when block exits

ExitStack lets you manage a dynamic number of context managers. Useful when you don't know how many at compile time.

Multiple context managers

with open("input.txt") as fin, open("output.txt", "w") as fout:
  fout.write(fin.read())

Comma-separated. Each manager's __exit__ runs in reverse order.

For Python 3.10+, parenthesized form for readability across lines:

with (
  open("input.txt") as fin,
  open("output.txt", "w") as fout,
  Lock() as lock,
):
  ...

Error handling inside context managers

@contextmanager
def database():
  conn = connect()
  try:
    yield conn
    conn.commit()      # commit on success
  except Exception:
    conn.rollback()    # rollback on failure
    raise              # re-raise
  finally:
    conn.close()

Idiomatic transaction management. Commit if the block succeeded, rollback if it raised, always close. The pattern shows up everywhere: HTTP sessions, file locks, anything with paired open/close.

Re-entrance and reuse

mgr = Timer()
with mgr:    # works
  ...
with mgr:    # works again only if Timer is reentrant
  ...

Class-based managers can be reused if __enter__ is idempotent. Generator-based ones (@contextmanager) are single-use — calling with mgr: after consumption raises RuntimeError. Create a fresh manager each time.

When NOT to use a context manager

  • One-off cleanup that's clear from context (a try/finally is sometimes simpler).
  • "Setup, do thing, teardown" but the setup/teardown isn't paired (just call functions).

The rule: context managers shine when the setup/teardown coupling is invariant — same teardown for every use.

Common stumbles

Forgetting to yield in @contextmanager. The function never enters the with block; instead it raises RuntimeError.

Yielding the wrong thing. yield provides the as value. If you yield nothing, as gets None.

Bare try without finally. Cleanup must be in finally, not just after yield. Otherwise an exception in the block skips cleanup.

return in __exit__ confusion. return True suppresses, return False (or no return) propagates. Default behavior should be propagation.

Reusing a generator-based manager. Single use. Make a fresh mgr = my_manager() each time, or convert to a class.

Catching BaseException. __exit__ shouldn't swallow KeyboardInterrupt or SystemExit. Be specific about what you handle.

What's next

Lesson 26: regular expressions. re.match, re.search, re.findall, re.sub, character classes, groups.

Recap

with mgr as x: ensures setup/teardown. Class form: __enter__ (returns as value) and __exit__(exc_type, exc_val, exc_tb) (return True to suppress). Generator form: @contextmanager decorator on a function with try/yield/finally. Use for file I/O, locks, DB transactions, temporarily modifying global state. ExitStack for dynamic composition. Single-use vs reentrant — know the difference.

Next lesson: regular expressions.

Ready? Take the quiz on the full lesson page →
Test what you've learned. Watch the lesson and try the interactive quiz on the same page.