Functions, Closures and Decorators

How to treat your functions well.
Python Academy
Author

bwrob

Published

October 23, 2025

In this part of the Python Academy, we’ll look at how Python’s functions work. We’ll start with the basics and then check out some more advanced topics like closures and decorators. Getting a handle on these concepts will help you write cleaner and more efficient Python code.

Functions

In Python, functions are “first-class citizens”. This just means you can treat them like any other variable. You can:

  • assign them to variables,
  • pass them as arguments to other functions,
  • return them from functions.

This is a core concept in Python that allows for some powerful patterns. Let’s use a function from our previous post on Fibonacci numbers to see this in action.

def fibonacci_iterative(n: int) -> int:
    """Calculate the nth Fibonacci number using an iterative approach."""
    if n < 0:
        raise ValueError("Fibonacci is not defined for negative numbers.")
    if n < 2:
        return n

    a, b = 0, 1
    for _ in range(n - 1):
        a, b = b, a + b
    return b

Because functions are objects, they come with some handy attributes. For instance, you can get the docstring with __doc__ and the function’s name with __name__.

print(fibonacci_iterative.__doc__)
print(fibonacci_iterative.__name__)
Calculate the nth Fibonacci number using an iterative approach.
fibonacci_iterative

And just like other objects, functions inherit from the object base class.

isinstance(fibonacci_iterative, object)
True

As you can see, functions in Python are more than just Callable blocks of code. Next up, we’ll see how this flexibility allows us to build closures and decorators.

Benchmarking Fibonacci Implementations

To really drive home the point that functions are first-class citizens, let’s put a few of our Fibonacci functions into a list and pass them to a benchmarking function. This function will test each one and see how they perform.

Show the code
def fibonacci_iterative(n: int) -> int:
    """Calculate the nth Fibonacci number using an iterative approach."""
    if n < 0:
        raise ValueError("Fibonacci is not defined for negative numbers.")
    if n < 2:
        return n

    a, b = 0, 1
    for _ in range(n - 1):
        a, b = b, a + b
    return b


def fibonacci_recursive(n: int) -> int:
    """Calculate the nth Fibonacci number using a recursive approach."""
    if n < 0:
        raise ValueError("Fibonacci is not defined for negative numbers.")
    if n < 2:
        return n
    return fibonacci_recursive(n - 1) + fibonacci_recursive(n - 2)


FIBONACCI_CACHE: dict[int, int] = {}


def fibonacci_recursive_cached(n: int) -> int:
    """Calculate the nth Fibonacci number using recursion with memoization."""
    if n < 0:
        raise ValueError("Fibonacci is not defined for negative numbers.")
    if n < 2:
        return n

    if n in FIBONACCI_CACHE:
        return FIBONACCI_CACHE[n]

    result = fibonacci_recursive_cached(n - 1) + fibonacci_recursive_cached(n - 2)

    FIBONACCI_CACHE[n] = result
    return result
import time
from typing import Callable


def benchmark(functions: list[Callable[[int], int]], n: int):
    """Benchmarks a list of functions."""
    for func in functions:
        start = time.perf_counter()
        func(n)
        end = time.perf_counter()
        print(f"Function {func.__name__} took {end - start:.6f} seconds.")


fib_functions = [fibonacci_recursive, fibonacci_recursive_cached, fibonacci_iterative]
benchmark(fib_functions, 30)
Function fibonacci_recursive took 0.081621 seconds.
Function fibonacci_recursive_cached took 0.000008 seconds.
Function fibonacci_iterative took 0.000003 seconds.

Closures

A closure is basically a function that remembers the environment in which it was created. Let’s use our get_greeter function to see what that means.

def get_greeter(greeting: str) -> Callable:
    """Returns a greeter function."""

    def greeter(name: str) -> str:
        return f"{greeting}, {name}!"

    return greeter


good_morning_greeter = get_greeter("Good morning")
print(good_morning_greeter("World"))
Good morning, World!

When we call get_greeter("Good morning"), we get a new greeter function back. This new function, which we’ve assigned to good_morning_greeter, “remembers” that the greeting variable was “Good morning”.

This is what a closure does: it “closes over” the variables from the scope where it was created. So even though the get_greeter function has finished running, good_morning_greeter still has access to the greeting variable.

We can even peek inside the closure with the __closure__ attribute, which shows us the variables it has captured.

good_morning_greeter.__closure__[0].cell_contents
'Good morning'

This shows that the value "Good morning" is tucked away in our good_morning_greeter function’s closure.

Closures are a key ingredient for decorators, which we’ll get to next. They also let us create “function factories” that can produce specialized functions.

For a more mathematical example, let’s create a function factory that builds a polynomial function from a tuple of coefficients.

A polynomial is defined as \(P(x) = \sum_{i=0}^{n} a_i x^i = a_n x^n + a_{n-1} x^{n-1} + \dots + a_1 x + a_0\).

from typing import Tuple

def polynomial_factory(coefficients: Tuple[float, ...]) -> Callable:
    """
    A factory that creates a polynomial function from a tuple of coefficients.
    The coefficients are ordered from the highest power to the lowest.
    """
    def polynomial(x: float) -> float:
        """
        Evaluates the polynomial for a given x.
        """
        return sum(c * (x ** i) for i, c in enumerate(reversed(coefficients)))

    return polynomial

# P(x) = 2x^2 + 3x + 5
p1 = polynomial_factory((2, 3, 5))

# Q(x) = x^3 - 8
p2 = polynomial_factory((1, 0, 0, -8))

print(p1(5))
print(p2(5))
70
117

Here, polynomial_factory returns a new polynomial function. This new function’s closure captures the coefficients tuple. So, p1 will always be the polynomial \(2x^2 + 3x + 5\), and p2 will always be \(x^3 - 8\).

Decorators

Decorators are a neat way to add functionality to functions or classes without changing their code. Think of it as wrapping a function in another function to give it extra abilities.

In the last post, we used the @cache decorator to speed up our recursive Fibonacci function. Let’s try to build a simple version of that decorator ourselves.

def my_cache(func: Callable) -> Callable:
    """A simple cache decorator."""
    _cache = {}
    def wrapper(*args):
        if args in _cache:
            return _cache[args]
        result = func(*args)
        _cache[args] = result
        return result
    return wrapper

@my_cache
def fibonacci_cached_by_me(n: int) -> int:
    """Calculate the nth Fibonacci number using recursion with our own cache decorator."""
    if n < 0:
        raise ValueError("Fibonacci is not defined for negative numbers.")
    if n < 2:
        return n
    return fibonacci_cached_by_me(n - 1) + fibonacci_cached_by_me(n - 2)

fibonacci_cached_by_me(30)
832040

The @my_cache line is just a cleaner way of writing fibonacci_cached_by_me = my_cache(fibonacci_cached_by_me). A decorator is just a function that takes another function as input and returns a modified function.

Decorators can also take arguments. To do this, we create a “decorator factory” - a function that returns a decorator. Here’s one that repeats a function call a few times.

def repeat(times: int) -> Callable:
    """A decorator that repeats a function call a given number of times."""

    def decorator(func: Callable) -> Callable:
        def wrapper(*args, **kwargs):
            for _ in range(times):
                result = func(*args, **kwargs)
            return result

        return wrapper

    return decorator


@repeat(3)
def say_hello(name: str):
    print(f"Hello, {name}!")


say_hello("World")
Hello, World!
Hello, World!
Hello, World!

In this case, @repeat(3) is the same as writing say_hello = repeat(3)(say_hello).

You’ll see decorators all over the place in Python frameworks like Flask (for routing) and pytest (for fixtures). They’re great for keeping your code clean and organized.

Note

Download the whole code here.

Back to top