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.