Timothy was debugging a file processing script when Margaret noticed something in his error logs. “Timothy, you’re getting ‘too many open files’ errors. Are you closing your file handles?”
Timothy looked defensive. “I am! Well… most of the time. Sometimes I forget if there’s an error in the middle.”
Margaret pulled up his code:
def process_config():
f = open('config.txt', 'r', encoding='utf-8')
data = f.read()
# Process data...
if 'error' in data:
return None # Oops - file never closed!
f.close()
return data
“See the problem?” Margaret asked. “If that early return happens, the file stays open. Python might close it when the object is garbage collected, but that’s not guaranteed and can lead to resource exhaustion.”
The Problem: Manual Resource Cleanup
Timothy groaned. “So I need to add f.close() before every return statement? And what if an exception happens?”
“Exactly. The traditional solution is a try/finally block:”
def process_config():
f = open('config.txt', 'r', encoding='utf-8')
try:
data = f.read()
if 'error' in data:
return None
return data
finally:
f.close()
“Now the file always closes, even if you return early or an exception occurs. But look at how much ceremony that adds.”
Timothy frowned. “All that boilerplate just to guarantee cleanup? There has to be a better way.”
The Solution: Context Managers
Margaret showed him the with statement:
def process_config():
with open('config.txt', 'r', encoding='utf-8') as f:
data = f.read()
if 'error' in data:
return None
return data
# File automatically closed here, no matter what
“Wait,” Timothy said. “That’s it? The with statement handles the closing automatically?”
“Exactly. Let me show you the structure.”
Tree View:
process_config()
With open('config.txt', 'r', encoding='utf-8') as f
data = f.read()
If 'error' in data
└── Return None
Return data
English View:
Function process_config():
With open('config.txt', 'r', encoding='utf-8') as f:
Set data to f.read().
If 'error' in data:
Return None.
Return data.
“Look at the structure,” Margaret said. “The with block has a clear entry point (open(...)) and an exit point (end of the indented block). Python guarantees that when you exit that block – whether by reaching the end, returning early, or raising an exception – the file gets closed.”
Timothy traced through it. “So no matter which return statement executes, or if an exception happens, the file closes when we leave the with block?”
“Guaranteed. The with statement is a context manager. It sets up resources on entry and cleans them up on exit, automatically.”
How Context Managers Work
“How does Python know what to clean up?” Timothy asked.
Margaret explained: “Objects that work with with statements implement two special methods: __enter__ and __exit__. Let me show you a simple context manager:”
class FileLogger:
def __init__(self, filename):
self.filename = filename
self.file = None
def __enter__(self):
print(f"Opening {self.filename}")
self.file = open(self.filename, 'w', encoding='utf-8')
return self.file
def __exit__(self, exc_type, exc_val, exc_tb):
print(f"Closing {self.filename}")
if self.file:
self.file.close()
return False # Don't suppress exceptions
# Usage
with FileLogger('output.log') as log:
log.write('Starting processn')
log.write('Processing datan')
# File automatically closed here
Tree View:
class FileLogger
__init__(self, filename)
self.filename = filename
self.file = None
__enter__(self)
print(f'Opening {self.filename}')
self.file = open(self.filename, 'w', encoding='utf-8')
Return self.file
__exit__(self, exc_type, exc_val, exc_tb)
print(f'Closing {self.filename}')
If self.file
└── self.file.close()
Return False
With FileLogger('output.log') as log
log.write('Starting processn')
log.write('Processing datan')
English View:
Class FileLogger:
Function __init__(self, filename):
Set self.filename to filename.
Set self.file to None.
Function __enter__(self):
Evaluate print(f'Opening {self.filename}').
Set self.file to open(self.filename, 'w', encoding='utf-8').
Return self.file.
Function __exit__(self, exc_type, exc_val, exc_tb):
Evaluate print(f'Closing {self.filename}').
If self.file:
Evaluate self.file.close().
Return False.
With FileLogger('output.log') as log:
Evaluate log.write('Starting processn').
Evaluate log.write('Processing datan').
“See the flow?” Margaret pointed out. “When Python executes with FileLogger('output.log') as log, it calls __enter__(), which returns the file object that gets assigned to log. When the block ends, Python calls __exit__(), which closes the file.”
Timothy watched the output:
Opening output.log
Closing output.log
“So __enter__ runs at the start of the with block, and __exit__ runs at the end, guaranteed?”
“Exactly. Even if an exception occurs inside the block, __exit__ still runs. That’s the guarantee.”
Exception Handling in Context Managers
“What are those parameters in __exit__?” Timothy asked. “The exc_type, exc_val, exc_tb?”
Margaret explained: “If an exception occurs inside the with block, Python passes the exception information to __exit__. You can examine it and decide whether to suppress the exception or let it propagate.”
She showed him:
class ErrorLogger:
def __enter__(self):
print("Entering context")
return self
def __exit__(self, exc_type, exc_val, exc_tb):
if exc_type is not None:
print(f"Exception occurred: {exc_type.__name__}: {exc_val}")
return True # Suppress the exception
print("Exiting normally")
return False
with ErrorLogger():
print("Working...")
raise ValueError("Something went wrong!")
print("Continuing after exception")
The output:
Entering context
Working...
Exception occurred: ValueError: Something went wrong!
Continuing after exception
“See? The exception was raised, __exit__ received the exception details, logged them, and returned True to suppress it. The code continued normally.”
Timothy was impressed. “So context managers can handle cleanup and exception management?”
“Right. That’s why they’re so powerful for resource management.”
Multiple Context Managers
“Can I use multiple with statements together?” Timothy asked.
“Absolutely. You can nest them or combine them:”
# Nested
with open('input.txt', 'r', encoding='utf-8') as infile:
with open('output.txt', 'w', encoding='utf-8') as outfile:
outfile.write(infile.read())
# Combined (Python 3.1+)
with open('input.txt', 'r', encoding='utf-8') as infile,
open('output.txt', 'w', encoding='utf-8') as outfile:
outfile.write(infile.read())
Tree View:
With open('input.txt', 'r', encoding='utf-8') as infile, open('output.txt', 'w', encoding='utf-8') as outfile
outfile.write(infile.read())
English View:
With open('input.txt', 'r', encoding='utf-8') as infile, open('output.txt', 'w', encoding='utf-8') as outfile:
Evaluate outfile.write(infile.read()).
“Both files are guaranteed to close, in reverse order. The last one opened closes first.”
When to Use Context Managers
Timothy was starting to see the pattern. “So context managers are for anything that needs setup and cleanup?”
Margaret listed the common use cases:
“Use context managers for:
- File operations (guaranteed close)
- Database connections (guaranteed commit/rollback)
- Locks and semaphores (guaranteed release)
- Network connections (guaranteed disconnect)
- Temporary state changes (guaranteed restore)
- Any resource that needs cleanup”
She showed him a threading example:
import threading
lock = threading.Lock()
# Without context manager - risky ❌
lock.acquire()
try:
# Critical section
pass
finally:
lock.release()
# With context manager - safe ✅
with lock:
# Critical section
pass
“The lock is guaranteed to release, even if an exception occurs in the critical section.”
Creating Simple Context Managers
“Do I always have to write a class with __enter__ and __exit__?” Timothy asked.
“No. Python provides contextlib for simpler cases:”
from contextlib import contextmanager
@contextmanager
def timer():
import time
start = time.time()
print("Timer started")
yield
end = time.time()
print(f"Timer stopped. Elapsed: {end - start:.2f}s")
with timer():
# Code to time
sum(range(1000000))
Tree View:
@contextmanager
timer()
import time
start = time.time()
print('Timer started')
yield
end = time.time()
print(f'Timer stopped. Elapsed: {end - start:.2f}s')
With timer()
sum(range(1000000))
English View:
Decorator @contextmanager
Function timer():
Import module time.
Set start to time.time().
Evaluate print('Timer started').
Yield control.
Set end to time.time().
Evaluate print(f'Timer stopped. Elapsed: {end - start:.2f}s').
With timer():
Evaluate sum(range(1000000)).
“The @contextmanager decorator turns a generator into a context manager. Everything before yield is __enter__, everything after is __exit__.”
Timothy nodded. “So I can write a simple function instead of a whole class for basic context managers?”
“Exactly. Use classes when you need state or complex exception handling. Use @contextmanager for simple cases.”
The Guarantee
Margaret pulled the lesson together. “The key insight is the guarantee. Manual cleanup requires discipline – you have to remember to close, release, or restore. Context managers provide a structural guarantee: setup happens, your code runs, cleanup happens. No exceptions, no early returns, no forgotten cleanup.”
Timothy looked at his file-processing code. “So by using with open(...), I’m not just saving typing. I’m guaranteeing that the file closes, no matter what happens in my code?”
“That’s exactly right. The structure enforces correctness. That’s the power of context managers.”
Analyze Python structure yourself: Download the Python Structure Viewer – a free tool that shows code structure in tree and plain English views. Works offline, no installation required.
Aaron Rose is a software engineer and technology writer at tech-reader.blog and the author of Think Like a Genius.
