Prerequisite:

Once you have the basics of Python down, functions are the single biggest lever for writing code that’s readable, testable, and reusable. This post goes deeper than syntax - it covers how functions actually work in Python, and how to use them as a design tool.

Why Functions Matter

Three reasons to reach for a function:

  1. Avoid repetition. If you copy-paste code, a bug in that code now lives in two places. A function fixes it once.
  2. Name concepts. calculate_discount(price, rate) communicates intent. Twenty lines of arithmetic do not.
  3. Enable testing. A function with inputs and outputs is easy to test. Code scattered across a script is not.

Function Anatomy

def add(a, b):
    """Return the sum of a and b."""
    return a + b
  • a and b are parameters - the names in the definition.
  • When you call add(3, 5), the values 3 and 5 are arguments.
  • The """...""" string immediately after def is a docstring. Write one for every non-trivial function.
  • return sends a value back to the caller. A function without return returns None.

Default arguments let callers omit values they don’t care about:

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

greet("Alice")          # "Hello, Alice!"
greet("Bob", "Hi")      # "Hi, Bob!"

Scope: The LEGB Rule

When Python looks up 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, etc.)
x = "global"

def outer():
    x = "enclosing"

    def inner():
        x = "local"
        print(x)   # "local"

    inner()
    print(x)       # "enclosing"

outer()
print(x)           # "global"

Each function has its own local scope. Assignments inside a function don’t affect the outer scope unless you explicitly say so.

The global Keyword (and Why to Avoid It)

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

count = 0

def increment():
    global count
    count += 1

This works, but it creates hidden dependencies between functions and the module state. Prefer returning values and passing state explicitly. Code that uses global is harder to test and reason about.

Closures

A closure is an inner function that remembers variables from its enclosing scope, even after the outer function has returned:

def make_multiplier(factor):
    def multiply(x):
        return x * factor   # 'factor' is captured from the enclosing scope
    return multiply

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

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

double and triple are functions that each carry their own factor in memory. Closures are the mechanism behind decorators and many functional patterns.

First-Class Functions

In Python, functions are objects. You can pass them as arguments, store them in variables, and return them from other functions:

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

print(apply(str.upper, "hello"))   # "HELLO"
print(apply(abs, -42))             # 42

This makes it natural to write functions that operate on other functions - which is exactly what decorators do.

Decorators

A decorator is a function that wraps another function to add behaviour. The @ syntax is shorthand:

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.3142s

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

Pure Functions vs Side Effects

A pure function always returns the same output for the same input, and it doesn’t change anything outside itself:

# Pure
def add_tax(price, rate):
    return price * (1 + rate)

# Impure - modifies external state
total = 0
def add_to_total(amount):
    global total
    total += amount

Pure functions are easy to test (just check input → output), easy to reason about (no hidden state), and safe to call multiple times. Prefer them. Reserve impure functions for places where side effects are the point - writing to a file, sending a network request, printing output.

The Mutable Default Argument Gotcha

This is one of Python’s most common surprises:

# WRONG
def append_to(element, to=[]):
    to.append(element)
    return to

print(append_to(1))   # [1]
print(append_to(2))   # [2] - expected, but...
print(append_to(3))   # [1, 2, 3] - the same list is reused!

Default argument values are evaluated once when the function is defined, not each time it’s called. The fix is to use None as the sentinel:

def append_to(element, to=None):
    if to is None:
        to = []
    to.append(element)
    return to

Examples

Memoization wrapper - cache expensive results using a closure and a dict:

def memoize(func):
    cache = {}
    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(35))   # fast, even though naive Fibonacci is exponential

Simple pipeline with function composition:

def pipeline(*funcs):
    """Return a function that applies each func in sequence."""
    def run(value):
        for func in funcs:
            value = func(value)
        return value
    return run

clean = pipeline(str.strip, str.lower, str.split)
print(clean("  Hello World  "))   # ['hello', 'world']

Both examples use closures, first-class functions, and decorators - the three ideas that make Python functions genuinely powerful.


Read Next: