Helpful context:


In the early days of programming - the late 1940s and 1950s - there were no functions. Programmers wrote code as a flat sequence of instructions. If you needed to sort something in two different places, you wrote the sorting code in two different places. If there was a bug in that sorting code, you fixed it in two different places - unless you forgot one, which you often did.

This is called code duplication, and it was recognized almost immediately as the original sin of software. Every duplicated block of code is a future maintenance problem waiting to happen. The more copies of logic you have, the more places a bug can hide, the more places you need to update when requirements change.

Functions were invented as the solution. Not just a syntax feature - a philosophical commitment: name a computation once, invoke it anywhere.

The DRY Principle and What It Actually Means

DRY stands for Don’t Repeat Yourself, a principle articulated in The Pragmatic Programmer (1999). The full statement is worth knowing: “Every piece of knowledge must have a single, unambiguous, authoritative representation within a system.”

Note what it says: knowledge, not just code. Two different functions that compute the same tax rate in different ways violate DRY even if they share no lines. The knowledge - how tax is calculated - is duplicated in spirit. When the tax rate changes, you have to find and update both.

The practical implication: when you notice yourself writing the same logic more than once, stop. Extract it into a function, give it a name, and invoke it from both places. Now you have one place to look when something goes wrong.

But DRY has a subtler failure mode: premature extraction. If you abstract two things that look similar but are not actually the same knowledge, you create a fake shared abstraction. When requirements change for one but not the other, you have to surgically disentangle them. This is often worse than the original duplication. We will come back to this.

Function Anatomy

A Python function has a clear structure:

def calculate_discount(price: float, rate: float) -> float:
    """Apply a discount rate to a price and return the discounted value.
    
    Args:
        price: the original price, must be non-negative
        rate: discount rate as a decimal (0.1 means 10% off)
    
    Returns:
        the price after discount
    """
    if price < 0 or not 0 <= rate <= 1:
        raise ValueError(f"Invalid inputs: price={price}, rate={rate}")
    return price * (1 - rate)

The pieces:

  • Parameters (price, rate) are the names in the definition. Arguments are the values you pass when calling.
  • Type hints (: float, -> float) are optional but valuable - they document intent and enable tools like mypy to catch type errors before runtime.
  • Docstrings belong in every non-trivial function. They explain what the function does, not how it does it.
  • return sends a value back. A function without return implicitly returns None.

Default argument values let callers omit values they do not care about:

def greet(name, greeting="Hello", punctuation="!"):
    return f"{greeting}, {name}{punctuation}"

greet("Alice")                        # "Hello, Alice!"
greet("Bob", "Hi")                    # "Hi, Bob!"
greet("Carol", punctuation=".")       # "Hello, Carol."

Keyword arguments can be passed in any order, which makes call sites self-documenting.

Scope and the LEGB Rule

Every name in Python lives in a scope - a namespace that determines where it is visible. When Python encounters a name, it searches four scopes in order:

  1. Local - inside the current function
  2. Enclosing - inside any enclosing function (for nested functions)
  3. Global - the module’s top level
  4. Built-in - Python’s built-in names: len, print, range, etc.
x = "global"

def outer():
    x = "enclosing"

    def inner():
        x = "local"
        print(x)   # "local" - found in Local scope

    inner()
    print(x)       # "enclosing" - inner's assignment did not affect this x

outer()
print(x)           # "global" - outer's assignment did not affect this x

Each function creates an isolated namespace. Assignments inside a function stay inside. This is not a limitation - it is a feature. It is what makes functions composable: they do not silently modify the world around them.

What is Actually Happening in Memory

Here is the part most Python tutorials skip: scope is not magic, it maps directly to how the CPU and memory work.

When you call a function, the Python interpreter creates a stack frame - a block of memory on the call stack that stores:

  • The function’s local variables
  • The arguments it was called with
  • A return address (where to resume execution after the function returns)

When the function returns, its stack frame is popped. The local variables cease to exist. This is why local scope is local: the variables literally only exist for the duration of the call.

The global scope maps to a dictionary stored in the module object, which lives on the heap. Built-in scope is another dictionary, also on the heap, shared across all modules.

This is not Python-specific. Every language with lexical scope and function calls works this way. Understanding it at the memory level makes the behavior of closures, recursion, and stack overflows comprehensible rather than mysterious. (Stack frames and the call stack are covered in depth in the Memory Management - The Hidden Allocator in Every Program .)

The global Keyword: A Warning Sign

global lets a function write to a module-level variable:

count = 0

def increment():
    global count
    count += 1

This works, but it is almost always a design smell. Global mutable state creates hidden dependencies: to understand what increment does, you now have to know about count. To test increment, you have to manage count’s state. When two functions both modify count, their interactions can produce surprising behavior.

Prefer returning values and passing state explicitly. If you find yourself reaching for global, ask whether you should instead be passing count as a parameter and returning the new value.

Closures: Functions That Remember

Here is where scope gets interesting. What happens when an inner function refers to a variable from the enclosing scope, and then the enclosing function returns?

def make_multiplier(factor):
    def multiply(x):
        return x * factor   # 'factor' lives in the enclosing scope
    return multiply          # we return the inner function itself

double = make_multiplier(2)
triple = make_multiplier(3)

print(double(5))   # 10
print(triple(5))   # 15

When make_multiplier(2) returns, you might expect factor to disappear - its stack frame is gone. But double still works, and correctly returns x * 2. How?

The inner function multiply forms a closure - it captures the variables from its enclosing scope and keeps them alive in a special __closure__ attribute. Even though the stack frame is gone, the captured variable lives on, referenced by the closure.

You can verify this:

print(double.__closure__[0].cell_contents)   # 2
print(triple.__closure__[0].cell_contents)   # 3

Closures are how Python functions carry persistent state without using global variables. They are the mechanism behind decorators, callback functions, and many functional patterns. Every time you use functools.lru_cache, the cache itself is stored in a closure.

Higher-Order Functions

Python treats functions as first-class objects. You can store them in variables, pass them as arguments, and return them from other functions. A function that takes or returns a function is called a higher-order function:

def apply(func, value):
    return func(value)

apply(abs, -5)           # 5
apply(str.upper, "hi")   # "HI"

The built-in map, filter, and sorted are higher-order functions:

numbers = [-3, -1, 0, 2, 4]

# map: apply a function to every element
doubled = list(map(lambda x: x * 2, numbers))   # [-6, -2, 0, 4, 8]

# filter: keep elements where function returns True
positives = list(filter(lambda x: x > 0, numbers))   # [2, 4]

# sorted with a key function
words = ["banana", "apple", "cherry", "date"]
by_length = sorted(words, key=len)   # ["date", "apple", "banana", "cherry"]

lambda creates small anonymous functions inline. Use them for short, one-off operations. For anything complex enough to need a name, write a proper def.

Decorators: Higher-Order Functions in Practice

A decorator is a function that wraps another function, adding behavior before or after it runs. The @ syntax is shorthand for a common pattern:

import time

def timer(func):
    def wrapper(*args, **kwargs):
        start = time.time()
        result = func(*args, **kwargs)
        elapsed = time.time() - start
        print(f"{func.__name__} took {elapsed:.4f}s")
        return result
    return wrapper

@timer
def slow_sum(n):
    return sum(range(n))

slow_sum(10_000_000)
# slow_sum took 0.312s

@timer is exactly equivalent to writing slow_sum = timer(slow_sum) after the definition. The wrapper uses *args and **kwargs to accept any arguments, so it works with any function signature.

The inner wrapper is a closure - it captures func from the enclosing timer scope. Closures are what make decorators work.

Real-world decorators include @functools.lru_cache for memoization, @property for computed attributes, @staticmethod and @classmethod, Django’s @login_required, and Flask’s @app.route. They all follow the same structure.

Pure Functions: The Ideal

A pure function always returns the same output for the same inputs and produces no side effects - it does not modify external state, write to files, make network calls, or mutate its arguments:

# Pure - predictable, testable, safe to call multiple times
def discount_price(price: float, rate: float) -> float:
    return price * (1 - rate)

# Impure - modifies external state
total = 0.0
def add_to_total(amount: float) -> None:
    global total
    total += amount   # side effect

Pure functions are easy to test (just check input maps to expected output), easy to reason about (no hidden state), safe to memoize (same inputs always produce the same result), and safe to parallelize (no shared mutable state).

Impure functions are sometimes unavoidable - reading from a database, writing to a file, printing output are inherently side effects. But they should be pushed to the edges of your system. The core logic should be as pure as possible.

When Abstractions Hurt

Functions are a form of abstraction, and like all abstractions, they can be misapplied.

Over-abstraction is extracting code into a function before you understand the pattern well enough to name it correctly. If you create a function called process_data that does three different things depending on a flag, you have made the code harder to understand, not easier. The function promised abstraction and delivered complexity.

Premature parameterization is adding parameters for hypothetical future requirements. If you add three optional parameters to a function “in case we need them later,” you have made the function’s interface larger and harder to use without any actual benefit yet.

Abstraction layers that obscure are functions that wrap a simple operation in a name that hides what is actually happening. Calling execute_primary_operation() when you mean sort(items) is not abstraction, it is obfuscation.

The rule of thumb: abstract when you see duplication, not before. Two concrete cases teach you what an abstraction should look like. One case teaches you nothing.

Example: A Memoization Decorator

This example combines closures, higher-order functions, and decorators into something genuinely useful:

def memoize(func):
    cache = {}   # lives in the closure
    def wrapper(*args):
        if args not in cache:
            cache[args] = func(*args)
        return cache[args]
    return wrapper

@memoize
def fibonacci(n):
    if n < 2:
        return n
    return fibonacci(n - 1) + fibonacci(n - 2)

print(fibonacci(40))   # instant - each subproblem solved once

Without memoization, fibonacci(40) makes over a billion recursive calls. With it, each value is computed once and cached. The cache is a dict stored in the closure of wrapper - it persists across calls because the closure keeps it alive.

functools.lru_cache does this better (with size limits and thread safety), but building it yourself is the clearest possible demonstration of what closures can do.


Concept What it is Why it matters
Function Named, reusable computation Eliminates duplication, enables testing
Scope (LEGB) Order of name lookup Prevents accidental coupling between functions
Stack frame Per-call memory block Explains why local variables are local
Closure Function + captured environment Enables stateful functions without globals
Higher-order function Function that takes/returns functions Enables decorators, callbacks, pipelines
Pure function No side effects, same output for same input Testable, composable, safe to optimize

Read Next: