Python: Zero to Hero
Home/Functional Programming and Advanced Functions
Share

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:

  1. There is a nested function (a function inside a function).
  2. The inner function references a variable from the outer function.
  3. 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/elif chains.
  • 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.
  • nonlocal lets an inner function modify an enclosing variable. global reaches 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.

© 2026 Abhilash Sahoo. Python: Zero to Hero.