Python has four core metaphors that separate beginner code from expert code. They aren’t fancy tricks. Each one solves a specific, recurring problem. Understanding what problem they solve matters more than memorizing syntax.
Based on James Powell’s excellent talk at PyData 2017.
1. Decorators: Wrapping Behavior Around Functions
The problem: You want to add timing, logging, or authentication to 20 functions. Copy-pasting the same before/after code into each one is fragile and ugly.
The metaphor: A decorator wraps a function with before-and-after behavior, without modifying the function itself.
Without a decorator:
def add(a, b):
return a + b
# Every time you call it, you manually time it
import time
start = time.time()
result = add(2, 3)
elapsed = time.time() - start
print(f"add took {elapsed:.4f}s")
With a decorator:
import time
def timer(func):
def wrapper(*args, **kwargs):
start = time.time()
result = func(*args, **kwargs)
elapsed = time.time() - start
print(f"{func.__name__} took {elapsed:.4f}s")
return result
return wrapper
@timer
def add(a, b):
return a + b
@timer
def multiply(a, b):
return a * b
add(2, 3) # prints: add took 0.0000s
multiply(4, 5) # prints: multiply took 0.0000s
The @timer line is just syntactic sugar for add = timer(add). It takes the function, wraps it, and replaces it. One line instead of copy-pasting timing code everywhere.
When to use: Logging, timing, authentication, caching, retry logic. Anything that wraps behavior around a function without changing what the function does.
2. Generators: Lazy Computation, One Piece at a Time
The problem: You need to process a million items, but loading them all into memory at once will crash your program.
The metaphor: A generator computes one value at a time and pauses between values. It yields control back to the caller after each result.
Without a generator:
def get_squares(n):
result = []
for i in range(n):
result.append(i * i)
return result
# This creates a list of 10 million items in memory
squares = get_squares(10_000_000)
print(squares[0]) # You only needed the first one
With a generator:
def get_squares(n):
for i in range(n):
yield i * i
# Nothing is computed yet
squares = get_squares(10_000_000)
# Only computes the first value
print(next(squares)) # 0
print(next(squares)) # 1
# Or take just the first 5
for sq in get_squares(10_000_000):
if sq > 10:
break
print(sq) # 0, 1, 4, 9
The yield keyword pauses the function and returns a value. Next time you ask for a value, it resumes from where it paused. Memory usage stays constant regardless of how many items exist.
When to use: Reading large files line by line, streaming data, infinite sequences, any computation where you don’t need all results at once.
3. Context Managers: Pairing Setup with Teardown
The problem: You open a file, do some work, and forget to close it. Or an exception happens before the close call, and the file handle leaks.
The metaphor: A context manager pairs a setup action with a teardown action and guarantees the teardown always runs, even if an error occurs.
Without a context manager:
f = open("data.txt", "w")
f.write("hello")
# If an exception happens here, the file never gets closed
f.close()
With a context manager:
with open("data.txt", "w") as f:
f.write("hello")
# File is automatically closed, even if write() raises an exception
Building your own (using a generator + decorator, showing how the metaphors combine):
from contextlib import contextmanager
import time
@contextmanager
def timer(label):
start = time.time()
yield # This is where the "with" block runs
elapsed = time.time() - start
print(f"{label}: {elapsed:.4f}s")
with timer("data processing"):
total = sum(range(1_000_000))
# prints: data processing: 0.0312s
The code before yield is the setup. The code after yield is the teardown. The with block runs in between. If the block raises an exception, the teardown still runs.
When to use: File handles, database connections, locks, temporary directories, GPU memory allocation. Anything that needs cleanup.
4. Metaclasses: Enforcing Rules on Subclasses
The problem: You’re writing a library. Users will subclass your base class. You need to make sure they implement certain methods, or follow certain naming conventions. You can’t trust documentation alone.
The metaphor: A metaclass hooks into the class creation process. Since Python creates classes at runtime (they’re just objects), you can inspect and validate them as they’re being created.
The problem, concretely:
class Plugin:
def run(self):
raise NotImplementedError
class MyPlugin(Plugin):
pass # Forgot to implement run()
p = MyPlugin()
p.run() # Crashes at runtime, maybe in production
With a metaclass (the manual way):
class PluginMeta(type):
def __init__(cls, name, bases, namespace):
super().__init__(name, bases, namespace)
if bases: # Skip the base class itself
if 'run' not in namespace:
raise TypeError(f"{name} must implement run()")
class Plugin(metaclass=PluginMeta):
def run(self):
raise NotImplementedError
class MyPlugin(Plugin):
pass # TypeError: MyPlugin must implement run()
The error happens at class definition time, not at runtime. You catch the mistake immediately.
The standard library way (you rarely need to write metaclasses by hand):
from abc import ABC, abstractmethod
class Plugin(ABC):
@abstractmethod
def run(self):
pass
class MyPlugin(Plugin):
pass # TypeError: Can't instantiate abstract class
ABC uses a metaclass under the hood (ABCMeta). You get the same enforcement without writing the metaclass yourself.
When to use: Almost never directly. Use ABC and @abstractmethod instead. Metaclasses are justified when you need to enforce constraints that ABC can’t express (like “all method names must be lowercase” or “every subclass must register itself in a global registry”).
How They Fit Together
These four metaphors are mostly orthogonal. You can combine them:
from contextlib import contextmanager
def log_calls(func): # Decorator
def wrapper(*args, **kwargs):
print(f"Calling {func.__name__}")
return func(*args, **kwargs)
return wrapper
@contextmanager # Context manager (built with generator)
def database_connection(url):
conn = connect(url)
try:
yield conn # Generator yield point
finally:
conn.close()
@log_calls
def process_data():
with database_connection("db://localhost") as conn:
for row in conn.stream_rows(): # Generator (lazy iteration)
transform(row)
The decorator adds logging. The context manager handles connection cleanup. The generator streams rows without loading them all into memory. Each one does its job.
The Takeaway
Don’t memorize syntax. Remember what each metaphor is for:
| Metaphor | Problem it solves |
|---|---|
| Decorator | Wrap behavior around functions (timing, auth, logging) |
| Generator | Compute lazily, one value at a time (memory, streaming) |
| Context Manager | Pair setup with guaranteed teardown (files, connections) |
| Metaclass | Enforce rules on subclasses at definition time (libraries) |
Expert Python code doesn’t use every feature. It uses the right feature for the right problem.