Chapter 21: First-Class Functions and Closures
You know functions well — you've been writing them since Chapter 7. But there's something about Python functions that most beginners don't fully grasp until much later: functions are objects.
Not metaphorically. Literally. In Python, a function is a value — the same kind of value as an integer or a string. You can store a function in a variable, put it in a list, pass it to another function, or return it from a function. This is called first-class functions, and it unlocks a whole new way of writing code.
This chapter also covers closures — functions that remember the environment they were created in, even after that environment no longer exists. Closures are the foundation of decorators (Chapter 22), which are used everywhere in real Python code.
Functions Are Objects
Let's start with a simple demonstration:
def greet(name):
return f"Hello, {name}!"
# Functions are objects — you can assign them to variables
say_hello = greet
print(say_hello("Alice")) # Hello, Alice!
print(greet is say_hello) # True — same object, two names
greet and say_hello both point to the same function object. Writing say_hello = greet doesn't call the function — it just creates another name for it.
A function has a type, attributes, and an identity, just like any other object:
def add(a, b):
return a + b
print(type(add)) # <class 'function'>
print(add.__name__) # add
print(add.__doc__) # None (no docstring)
print(id(add)) # some memory address
Passing Functions as Arguments
Because functions are objects, you can pass them to other functions as arguments. A function that accepts or returns another function is called a higher-order function.
def apply(func, value):
"""Apply func to value and return the result."""
return func(value)
def double(n):
return n * 2
def square(n):
return n ** 2
def negate(n):
return -n
print(apply(double, 5)) # 10
print(apply(square, 5)) # 25
print(apply(negate, 5)) # -5
The same apply function does completely different things depending on which function you pass. The behavior is data — you supply it at call time.
A practical example: applying transformations to a list
def transform(items, func):
"""Return a new list with func applied to every item."""
return [func(item) for item in items]
numbers = [1, 2, 3, 4, 5]
print(transform(numbers, double)) # [2, 4, 6, 8, 10]
print(transform(numbers, square)) # [1, 4, 9, 16, 25]
print(transform(numbers, negate)) # [-1, -2, -3, -4, -5]
This is exactly what Python's built-in map() does. You just built your own version.
Sorting with a key function
You've already used this pattern — sorted() and .sort() both accept a key function:
students = [
{"name": "Alice", "gpa": 3.8},
{"name": "Bob", "gpa": 3.5},
{"name": "Carlos", "gpa": 3.9},
{"name": "Diana", "gpa": 3.7},
]
# Sort by GPA (ascending)
by_gpa = sorted(students, key=lambda s: s["gpa"])
# Sort by name
by_name = sorted(students, key=lambda s: s["name"])
# Sort by GPA descending
top_students = sorted(students, key=lambda s: s["gpa"], reverse=True)
for s in top_students:
print(f"{s['name']}: {s['gpa']}")
Output:
Carlos: 3.9
Alice: 3.8
Diana: 3.7
Bob: 3.5
The key parameter is a function. Python calls it on each item to get the sort value. Any callable works here.
Your turn: Write a function apply_all(value, *funcs) that applies a list of functions to a value in sequence. For example, apply_all(5, double, square, negate) should return -(5*2)^2 = -100. Test it with several combinations.
Storing Functions in Data Structures
Functions can live in lists, dictionaries, and any other collection.
def add(a, b): return a + b
def subtract(a, b): return a - b
def multiply(a, b): return a * b
def divide(a, b):
if b == 0:
raise ZeroDivisionError("Cannot divide by zero.")
return a / b
# Dictionary of operations
operations = {
"+": add,
"-": subtract,
"*": multiply,
"/": divide,
}
# A simple calculator using the dispatch table
def calculate(a, op, b):
if op not in operations:
raise ValueError(f"Unknown operator: {op!r}")
return operations[op](a, b)
print(calculate(10, "+", 5)) # 15
print(calculate(10, "*", 3)) # 30
print(calculate(10, "/", 4)) # 2.5
This pattern — a dictionary that maps strings to functions — is called a dispatch table. It replaces a long if/elif chain with a clean lookup. You'll see it constantly in real code.
# List of validators — run each one and collect errors
def is_not_empty(value):
return len(value.strip()) > 0, "Field cannot be empty."
def is_long_enough(value):
return len(value) >= 8, "Must be at least 8 characters."
def has_uppercase(value):
return any(c.isupper() for c in value), "Must contain an uppercase letter."
def has_digit(value):
return any(c.isdigit() for c in value), "Must contain a digit."
validators = [is_not_empty, is_long_enough, has_uppercase, has_digit]
def validate_password(password):
errors = []
for validator in validators:
passed, message = validator(password)
if not passed:
errors.append(message)
return errors
errors = validate_password("hello")
for e in errors:
print(f" [-] {e}")
print()
errors = validate_password("Hello123")
if not errors:
print(" [x] Password is valid.")
Output:
[-] Must be at least 8 characters.
[-] Must contain an uppercase letter.
[-] Must contain a digit.
[x] Password is valid.
A list of validation functions, looped over. Adding a new rule means adding one function to the list — no if chain to modify.
Returning Functions from Functions
A function can return another function. This is the foundation of closures and decorators.
def make_multiplier(factor):
"""Return a function that multiplies its argument by factor."""
def multiplier(n):
return n * factor
return multiplier # return the function, don't call it
double = make_multiplier(2)
triple = make_multiplier(3)
times_ten = make_multiplier(10)
print(double(5)) # 10
print(triple(5)) # 15
print(times_ten(5)) # 50
make_multiplier(2) doesn't return 10 — it returns a function that multiplies by 2. That function is stored in double and called later.
Notice that multiplier uses factor from the outer function make_multiplier. Even after make_multiplier finishes running, the inner function remembers the value of factor. That's a closure.
Closures — Functions That Remember
A closure is a function that captures variables from its enclosing scope and remembers them even after that scope has ended.
def make_counter(start=0):
count = start
def counter():
nonlocal count
count += 1
return count
return counter
count_a = make_counter()
count_b = make_counter(10)
print(count_a()) # 1
print(count_a()) # 2
print(count_a()) # 3
print(count_b()) # 11
print(count_b()) # 12
print(count_a()) # 4 — count_a has its own independent count
count_a and count_b are two separate closure instances. Each one has its own count variable, captured from its own call to make_counter. They don't interfere with each other.
The nonlocal keyword is required here — it tells Python that count refers to the variable from the enclosing function, not a new local variable.
What makes a closure?
A closure exists when:
- There is a nested function (a function inside a function).
- The inner function references a variable from the outer function.
- The outer function returns the inner function.
The inner function closes over the outer variable — that's where the name comes from.
Inspecting a closure
def make_adder(n):
def adder(x):
return x + n
return adder
add5 = make_adder(5)
add10 = make_adder(10)
print(add5(3)) # 8
print(add10(3)) # 13
# Inspect the closed-over variables
print(add5.__closure__) # (<cell at 0x...>,)
print(add5.__closure__[0].cell_contents) # 5
print(add10.__closure__[0].cell_contents) # 10
The __closure__ attribute holds the captured variables. Each one is a "cell" — a box that holds the value.
Factory Functions
A factory function is a function that creates and returns other functions, customized by the arguments you pass. It's one of the most useful closure patterns.
def make_validator(min_val, max_val, name="value"):
"""Return a validator function for a specific range."""
def validate(value):
if not isinstance(value, (int, float)):
raise TypeError(f"{name} must be a number.")
if value < min_val or value > max_val:
raise ValueError(
f"{name} must be between {min_val} and {max_val}, got {value}."
)
return True
return validate
validate_age = make_validator(0, 120, "Age")
validate_score = make_validator(0, 100, "Score")
validate_rating = make_validator(1, 5, "Rating")
validate_temp_c = make_validator(-273.15, 1e6, "Temperature")
try:
validate_age(25) # passes
validate_score(105) # raises ValueError
except ValueError as e:
print(e) # Score must be between 0 and 100, got 105.
try:
validate_rating("five") # raises TypeError
except TypeError as e:
print(e) # Rating must be a number.
Each validator is a specialized function created by the factory. They all share the same validation logic but have different ranges and names.
def make_formatter(prefix="", suffix="", width=0, fill=" "):
"""Return a string formatter function."""
def format_value(value):
text = f"{prefix}{value}{suffix}"
return text.center(width, fill) if width else text
return format_value
format_currency = make_formatter(prefix="$", suffix="", width=0)
format_percent = make_formatter(prefix="", suffix="%")
format_centered = make_formatter(prefix="[ ", suffix=" ]", width=30, fill="─")
print(format_currency(1234.56)) # $1234.56
print(format_percent(87.5)) # 87.5%
print(format_centered("Python")) # ────────[ Python ]────────
The nonlocal Keyword
nonlocal lets an inner function modify a variable from the enclosing (but not global) scope. Without it, assignment creates a new local variable.
def make_accumulator():
total = 0
def add(value):
nonlocal total # refer to the outer total
total += value
return total
return add
acc = make_accumulator()
print(acc(10)) # 10
print(acc(5)) # 15
print(acc(20)) # 35
Without nonlocal total, total += value would raise UnboundLocalError — Python would treat total as a local variable that doesn't exist yet.
Compare to the global keyword — nonlocal goes one level up (to the enclosing function), while global goes all the way to the module level. Always prefer nonlocal for closures; use global only when truly necessary.
Closures vs Classes
Closures and classes often solve the same problem — maintaining state across multiple calls. Here's the same counter written both ways:
# Closure version
def make_counter(start=0, step=1):
count = start
def counter():
nonlocal count
count += step
return count
return counter
c = make_counter(0, 2)
print(c(), c(), c()) # 2 4 6
# Class version
class Counter:
def __init__(self, start=0, step=1):
self.count = start
self.step = step
def __call__(self):
self.count += self.step
return self.count
c = Counter(0, 2)
print(c(), c(), c()) # 2 4 6
Both work. Choose based on complexity:
- Closure: Simple, one function, minimal state. Less code, harder to inspect or extend.
- Class: Multiple methods, more state, easier to test, inherit, or add features to.
Partial Application with Closures
Closures are a natural way to do partial application — fixing some arguments of a function to create a simpler, more specific version.
def power(base, exponent):
return base ** exponent
def make_power(exponent):
def powered(base):
return power(base, exponent)
return powered
square = make_power(2)
cube = make_power(3)
to_tenth = make_power(10)
print(square(5)) # 25
print(cube(3)) # 27
print(to_tenth(2)) # 1024
Python's functools.partial does this more cleanly (you saw it in Chapter 16). But understanding the closure version helps you understand why partial works the way it does.
Memoization with Closures
A memoization cache remembers the results of expensive function calls. If you call the function with the same arguments again, it returns the cached result instead of recalculating.
def memoize(func):
"""Wrap func with a memoization cache."""
cache = {}
def wrapper(*args):
if args not in cache:
cache[args] = func(*args)
return cache[args]
wrapper.__name__ = func.__name__
wrapper.cache = cache # expose the cache for inspection
return wrapper
def slow_fibonacci(n):
if n < 2:
return n
return slow_fibonacci(n - 1) + slow_fibonacci(n - 2)
fast_fibonacci = memoize(slow_fibonacci)
import time
start = time.time()
print(fast_fibonacci(35)) # 9227465
print(f"Time: {time.time() - start:.4f}s")
print(f"Cache size: {len(fast_fibonacci.cache)} entries")
memoize is a closure. The cache dictionary is created in memoize, captured by wrapper, and persists across all calls to wrapper. This is almost exactly what @functools.lru_cache does internally.
Putting It All Together: A Pipeline Builder
def pipeline(*funcs):
"""
Return a function that passes its input through each function in sequence.
The output of each function becomes the input of the next.
"""
def run(value):
for func in funcs:
value = func(value)
return value
return run
# String processing pipeline
clean = str.strip
lowercase = str.lower
capitalize = str.capitalize
def remove_punctuation(text):
return "".join(c for c in text if c.isalpha() or c.isspace())
def normalize_spaces(text):
return " ".join(text.split())
clean_text = pipeline(clean, lowercase, remove_punctuation, normalize_spaces, capitalize)
raw_inputs = [
" HELLO, WORLD!!! ",
" Python is AWESOME. ",
" too many spaces and CAPS ",
]
for raw in raw_inputs:
print(f"Before: {raw!r}")
print(f"After: {clean_text(raw)!r}")
print()
Output:
Before: ' HELLO, WORLD!!! '
After: 'Hello world'
Before: ' Python is AWESOME. '
After: 'Python is awesome'
Before: ' too many spaces and CAPS '
After: 'Too many spaces and caps'
pipeline is a factory function that returns a closure. Each pipeline captures a different set of functions in its funcs tuple. You can build as many pipelines as you need, each doing something different.
# Number processing pipeline
import math
normalize = lambda x: (x - min_val) / (max_val - min_val)
numbers = [10, 25, 5, 40, 15, 30]
min_val = min(numbers)
max_val = max(numbers)
process_numbers = pipeline(
lambda x: x ** 2,
lambda x: x - 100,
abs,
math.sqrt,
lambda x: round(x, 2),
)
results = [process_numbers(n) for n in numbers]
print(results) # [9.49, 5.0, 9.95, 17.32, 6.24, 13.23]
What You Learned in This Chapter
- Functions are first-class objects — they have type, identity, and can be stored in variables.
- Higher-order functions accept functions as arguments or return functions.
- A dispatch table (dict of functions) replaces long
if/elifchains. - Storing functions in a list lets you apply a pipeline of operations with a simple loop.
- A function can return another function — this is the foundation of closures and decorators.
- A closure is an inner function that captures variables from its enclosing scope, remembering them after the outer function has returned.
nonlocallets an inner function modify an enclosing variable.globalreaches the module level.- Factory functions create specialized functions from a common template.
- Closures are an alternative to classes for lightweight stateful functions.
- Partial application — fixing some arguments to create a more specific function — is naturally done with closures (or
functools.partial). - Memoization — caching function results — is a classic closure pattern, and the basis of
functools.lru_cache.
What's Next?
Closures lead directly to one of Python's most powerful and widely-used features: decorators.
In Chapter 22 you'll learn what decorators are, how to write them, how to stack them, how to make them accept arguments, and how they're used everywhere in real Python code — for logging, timing, authentication, caching, retry logic, and more. If you understood closures, decorators will click immediately.
Your turn: Write a retry(times) factory function. It takes a number times and returns a decorator that wraps a function so it retries up to times times if it raises an exception. After all retries are exhausted, re-raise the last exception. Test it with a function that randomly fails (use random.random() < 0.7 to simulate a 70% failure rate). Print a message each time it retries.