First-class functions: passing and returning functions

RoadmapsPython

Overview

In Python, functions are objects. This is called first-class function support. Because functions are objects, they can be: - Assigned to variables - Stored in collections - Passed as arguments to other functions - Returned from other functions Assigning a function to a variable lets you call the function through that variable name. You can also pass a function by name to another function as an argument. def normalize(text): return text.strip().lower() transform = normalize # assign function to a variable result = transform(' Alice ') # call via the variable def apply(fn, value): # accept a function as an argument return fn(value) apply(normalize, ' Bob ') # pass a function by name Returning functions enables the function factory pattern. The outer function creates and configures an inner function, then returns it. def make_prefixer(prefix): def add_prefix(text): return f'{prefix}: {text}' return add_prefix # return the inner function error = make_prefixer('ERROR') print(error('disk full')) # ERROR: disk full The inner function `add_prefix` remembers `prefix` from the outer scope even after `make_prefixer` has returned. This is called a closure. A common trap with closures is late binding of variables inside a loop. def make_multipliers(): # all returned functions will capture the final value of i (which is 2) return [lambda x: x * i for i in range(3)] multipliers = make_multipliers() print(multipliers[0](10)) # prints 20, not 0! The closure captures the variable itself, not its value at the time the function was created.

Higher-order functions (functions that take or return functions) are the foundation of decorators, middleware pipelines, retry wrappers, and dependency injection. Without first-class functions, each of these patterns would require explicit `subclassing` instead of composable functions.

Where used: FastAPI middleware and dependency injection (`Depends` receives a function), Retry decorator wrapping route handlers, Pydantic `field_validator` receiving a validation function

Why learn this

Code walkthrough

def make_prefixer(prefix):
    def add_prefix(text):
        return f'{prefix}: {text}'
    return add_prefix

error = make_prefixer('ERROR')
warn = make_prefixer('WARN')
print(error('disk full'))
print(warn('high memory'))

Focus: `make_prefixer` returns a new function each time; each returned function closes over its own `prefix` value — two independent prefixers from one factory.

Common mistakes

Glossary

closure
A function that remembers and has access to variables from the environment where it was created, even after that environment is gone. Example: `def inner(): return val`
subclassing
The process of creating a new class based on an existing class to inherit or modify its behavior. Example: `class Child(Parent):`

Recall questions

Understanding checks

What is the output of this code?

8 12

`multiply_by` is a function factory. It creates closures that remember the `factor` variable. `double` remembers `2`, `triple` remembers `3`. `double(4)` is `4 * 2 = 8`, `triple(4)` is `4 * 3 = 12`.

Why does this code fail with an error?

It calls `add_one()` immediately instead of passing the function object.

By adding parentheses in `apply_twice(add_one(), 5)`, we call `add_one` without arguments (which raises a `TypeError`) and pass its return value. We should pass the function itself: `apply_twice(add_one, 5)`.

What is the output of this code?

11 11

Due to late binding, both lambda functions capture the variable `i`, which has a final value of `1` when the loop finishes. Therefore, both adders add `1` to `10`.

Practice tasks

Pipeline runner

Modify the `process_text` function. Currently, it hardcodes calling `str.strip`, `str.lower`, and `str.split`. Change it to `run_pipeline(value, steps)`, which accepts a starting value and a list of functions (`steps`). It should iterate through the steps, passing the result of each step to the next function, and return the final value.

Challenge

Retry wrapper

Write `with_retry(fn, attempts)` that returns a new function. When the new function is called with any arguments, it tries `fn(*args, **kwargs)` up to `attempts` times, printing `'attempt <n>'` each try. On success it returns the result; if all attempts fail it raises the last exception. Test by wrapping a function that raises on the first two calls but succeeds on the third.

Continue learning

Previous: Defining functions and return values

Previous: LEGB rule: variable scope in Python

Previous: Lambda functions and when to use them

Return to Python Roadmap