AI SWE Prep
0/9in Python Foundations
Python Foundations

Functions, Closures & Decorators

First-class functions, closures, and decorators — the machinery behind caching, retries, and most framework 'magic'.

~14 minLesson 7 of 60
Your progressNot started

Python functions are ordinary objects. You can pass them to other functions, return them, store them on instances, and wrap them. That one fact explains callbacks, decorators, dependency injection, retries, caching, and much of the “magic” in Python frameworks.

The sharp edge is scope. Closures remember variables, not frozen values, and function defaults are created once. Learn those two rules and decorators become a small, predictable tool instead of ceremony.

The mental model

A function definition creates a function object and binds a name to it. Calling a function executes its body. Passing a function moves the object around without calling it.

def normalize(text: str) -> str:
    return " ".join(text.lower().split())


def apply_all(value: str, funcs):
    for func in funcs:
        value = func(value)
    return value

clean = apply_all("  Hello   WORLD ", [normalize, str.strip])

A closure is a function that uses variables from an outer scope after that outer function has returned.

def make_prefixer(prefix: str):
    def add_prefix(text: str) -> str:
        return f"{prefix}{text}"

    return add_prefix

warn = make_prefixer("WARN: ")
warn("slow request")

The inner function keeps a reference to prefix. That is powerful, but it means you need to understand what value is being referenced.

First-class functions make behavior injectable

Instead of hard-coding a behavior, accept a function. That keeps code testable and keeps policy separate from mechanics.

def retry_if(operation, should_retry, attempts: int):
    for attempt in range(1, attempts + 1):
        try:
            return operation()
        except Exception as exc:
            if attempt == attempts or not should_retry(exc):
                raise

Production code uses this constantly: sort keys, validation callbacks, serializer functions, dependency injection, and mockable boundaries in tests.

Closures and late binding

Closures capture names, not snapshots. The classic bug appears when creating functions in a loop:

funcs = []
for i in range(3):
    funcs.append(lambda: i)

[f() for f in funcs]      # [2, 2, 2]

Each lambda refers to the same i, and after the loop ends i is 2. Bind the current value as a default argument or use a helper function.

funcs = []
for i in range(3):
    funcs.append(lambda i=i: i)

[f() for f in funcs]      # [0, 1, 2]

Default arguments are evaluated when the function is defined, which is useful here and dangerous with mutable defaults later.

*args and **kwargs

*args captures extra positional arguments as a tuple. **kwargs captures extra keyword arguments as a dict. Decorators use both so they can wrap functions with any signature.

def log_call(func):
    def wrapper(*args, **kwargs):
        print(f"calling {func.__name__}")
        return func(*args, **kwargs)

    return wrapper

Use them to forward arguments, not to hide sloppy APIs. If you own the function, a precise signature is usually better.

Decorators are function transformers

A decorator is just syntax for rebinding a function to the result of another function.

@decorator
def work():
    ...

# means:
def work():
    ...
work = decorator(work)

Always use functools.wraps in the wrapper. It preserves the original function’s name, docstring, annotations, and enough metadata for debugging and test tools.

from functools import wraps


def traced(func):
    @wraps(func)
    def wrapper(*args, **kwargs):
        print(f"start {func.__name__}")
        return func(*args, **kwargs)

    return wrapper

Decorators with arguments add one more layer: a function that receives the configuration and returns the real decorator.

@timer @retry — a call through the wrappersStep 1 / 7
caller: result = fetch(url)
@timer
@retry
fetch
Caller writes fetch(url). But fetch has been decorated twice, so the name now points at the outermost wrapper.
A decorator returns a new function that wraps the old one. Stacked decorators nest outermost-first on the way in, innermost-first on the way out.

Timing, retry, and cache decorators

These three patterns cover most practical decorators:

  • Timing — measure elapsed time around the call.
  • Retry — catch selected transient exceptions and try again.
  • Cache — remember results for pure functions; prefer functools.cache or functools.lru_cache before writing your own.
from functools import lru_cache


@lru_cache(maxsize=1024)
def tokenize(text: str) -> tuple[str, ...]:
    return tuple(text.lower().split())

The hidden contract is purity. Caching a function that depends on time, random state, environment variables, or a database can preserve the wrong answer.

When to reach for this

Variations

Try it

These exercises focus on the contracts: preserve return values, preserve metadata, and do not let hidden state leak between calls.

ExerciseEasy
  • Decorator
  • wraps

Write a @timed decorator

Create a decorator that prints how long a function took and returns the original result unchanged. It should work with any positional or keyword arguments.

Approach. Wrap the call in perf_counter(), forward *args and **kwargs, and use functools.wraps so the wrapped function still looks like itself.

Show solution
from functools import wraps
from time import perf_counter


def timed(func):
    @wraps(func)
    def wrapper(*args, **kwargs):
        started_at = perf_counter()
        try:
            return func(*args, **kwargs)
        finally:
            elapsed = perf_counter() - started_at
            print(f"{func.__name__} took {elapsed:.3f}s")

    return wrapper


@timed
def parse_batch(path: str) -> int:
    return load_and_parse(path)

The finally block logs even when the function raises, but it does not swallow the exception. The caller still gets the original result or the original failure.

ExerciseMedium
  • Decorator
  • Retry

Write a parametrized @retry(n) decorator

Implement @retry(tries=3) for transient failures. It should retry exceptions up to tries total attempts and re-raise the last exception if every attempt fails.

Approach. A decorator with arguments has three layers: the outer function receives configuration, the middle receives func, and the wrapper receives the call arguments.

Show solution
from functools import wraps
from time import sleep


def retry(tries: int, delay: float = 0.0, exceptions=(Exception,)):
    if tries < 1:
        raise ValueError("tries must be at least 1")

    def decorate(func):
        @wraps(func)
        def wrapper(*args, **kwargs):
            for attempt in range(1, tries + 1):
                try:
                    return func(*args, **kwargs)
                except exceptions:
                    if attempt == tries:
                        raise
                    if delay:
                        sleep(delay)

        return wrapper

    return decorate


@retry(tries=3, delay=0.2, exceptions=(TimeoutError,))
def fetch_embedding(text: str) -> list[float]:
    return call_embedding_api(text)

exceptions is configurable so you do not retry programming errors by accident. In real services, add jitter and a maximum delay; the core shape stays the same.

GotchaMedium
  • Defaults
  • State

Fix the mutable default argument

What does this print, and how do you make each call independent?

def add_tag(tag, tags=[]):
    tags.append(tag)
    return tags

print(add_tag("ai"))
print(add_tag("ml"))

Approach. Default values are evaluated once, when the function is defined. The same list is reused on every call. Use None as a sentinel and create a new list inside the function.

Show solution
def add_tag(tag: str, tags: list[str] | None = None) -> list[str]:
    if tags is None:
        tags = []
    tags.append(tag)
    return tags


print(add_tag("ai"))          # ["ai"]
print(add_tag("ml"))          # ["ml"]
print(add_tag("cv", ["ml"])) # ["ml", "cv"]

The original version prints ['ai'] and then ['ai', 'ml'] because both calls share one default list. Immutable defaults like None, 0, or "" are fine; mutable defaults are almost always a bug.

The classic pitfall

Your progressNot started