Python Functions

A function is a reusable, named block of code that performs a specific task, optionally accepting inputs and producing an output. Functions let you break a program into smaller, testable pieces, avoid repeating the same logic in multiple places, and give meaningful names to steps in your program. Python treats functions as first-class objects, so understanding how they work under the hood — how arguments are bound, how scope works, how defaults behave — pays off everywhere else in the language, from callbacks to decorators.

Overview: How Functions Work

You create a function with the def statement. When Python executes a def, it does not run the function body yet — it builds a function object (containing the compiled bytecode, the parameter list, and the default values) and binds that object to the function’s name in the current namespace. This is why a function can be defined near the top of a file and called near the bottom: the name just has to exist by the time the call happens.

Calling a function — result = my_function(1, 2) — creates a brand-new local namespace (a “stack frame”). Python binds the arguments you passed to the parameter names inside that frame, executes the function’s statements top to bottom, and then destroys the frame when the function returns. Any variables created inside the function only exist for the duration of that call; they do not leak into the caller’s scope.

Name lookup inside a function follows the LEGB rule: Python looks in the Local scope first, then any Enclosing function scope, then the Global (module) scope, and finally the Built-in scope (things like len or print). A function can read a global variable without any special syntax, but to reassign a global variable from inside a function you need the global keyword — otherwise Python treats the name as a new local variable.

Every function returns something. If the body reaches the end without hitting a return statement — or uses a bare return — the function returns None. A return statement immediately exits the function, skipping any remaining code, which makes it useful for early exits (guard clauses).

Because functions are ordinary objects, you can assign them to variables, store them in lists or dictionaries, and pass them as arguments to other functions. Default parameter values are evaluated exactly once, at def time, not on every call — this single fact explains one of the most common bugs in Python, covered below in Common Mistakes.

Syntax

def function_name(param1, param2=default_value, *args, kw_only=None, **kwargs):
    """Optional docstring describing what the function does."""
    # function body
    return some_value
Part Meaning
def Keyword that starts a function definition.
function_name The identifier used to call the function later. Use snake_case.
param1 A required positional parameter — the caller must supply it.
param2=default_value An optional parameter with a default, used if the caller omits it.
*args Collects any extra positional arguments into a tuple named args.
kw_only=None Anything after *args (or a bare *) can only be passed by keyword.
**kwargs Collects any extra keyword arguments into a dict named kwargs.
"""...""" A docstring — accessible via function_name.__doc__ or help().
return Sends a value back to the caller and exits the function immediately.

Examples

Example 1: A basic function

def calculate_area(width, height):
    return width * height

area = calculate_area(5, 3)
print(f"The area is {area}")
Output:
The area is 15

calculate_area takes two required positional parameters, multiplies them, and returns the result. The caller stores that returned value in area and prints it. Nothing is printed inside the function itself — printing is the caller’s decision, not the function’s job.

Example 2: Default arguments and keyword-only arguments

def describe_pet(name, animal_type="dog", *, age=None):
    description = f"{name} is a {animal_type}"
    if age is not None:
        description += f" who is {age} years old"
    return description

print(describe_pet("Rex"))
print(describe_pet("Whiskers", animal_type="cat", age=3))
print(describe_pet(name="Buddy", age=5))
Output:
Rex is a dog
Whiskers is a cat who is 3 years old
Buddy is a dog who is 5 years old

animal_type has a default, so callers may omit it. The bare * makes everything after it — here, age — keyword-only, so describe_pet("Rex", 5) would raise a TypeError; you must write age=5. This is a common pattern for parameters where a stray positional argument would be confusing.

Example 3: Variable-length positional arguments (*args)

def compute_stats(*numbers):
    total = sum(numbers)
    average = total / len(numbers)
    return total, min(numbers), average

total, minimum, average = compute_stats(4, 8, 15, 16, 23, 42)
print(f"Total: {total}")
print(f"Minimum: {minimum}")
print(f"Average: {average:.2f}")
Output:
Total: 108
Minimum: 4
Average: 18.00

*numbers collects any number of positional arguments into a tuple, so compute_stats works whether you pass 2 numbers or 200. The function returns three values at once as a tuple, and the caller unpacks them into total, minimum, and average in a single line.

Example 4: Variable-length keyword arguments (**kwargs)

def build_profile(first, last, **user_info):
    profile = {"first_name": first, "last_name": last}
    profile.update(user_info)
    return profile

user_profile = build_profile("Ada", "Lovelace", field="mathematics", birth_year=1815)
for key, value in user_profile.items():
    print(f"{key}: {value}")
Output:
first_name: Ada
last_name: Lovelace
field: mathematics
birth_year: 1815

**user_info gathers any keyword arguments the caller supplies beyond first and last into a dictionary. This is how functions like Django’s model constructors or dict-style APIs accept an open-ended set of named fields.

Under the Hood: Call Stack and Scope

Consider a recursive function, which makes the call stack especially visible:

def factorial(n):
    if n < 0:
        raise ValueError("n must be non-negative")
    if n <= 1:
        return 1
    return n * factorial(n - 1)

print(factorial(5))
Output:
120

Here is what Python does step by step:

  • Calling factorial(5) pushes a new frame onto the call stack with local variable n = 5.
  • Since 5 > 1, Python must evaluate 5 * factorial(4), which requires calling factorial(4) — a second frame is pushed, with its own independent n = 4.
  • This repeats down to factorial(1), which returns 1 directly without any further calls — this is the base case that stops the recursion.
  • Each pending frame now has the value it needed and returns in reverse order: factorial(1) returns 1, so factorial(2) returns 2 * 1 = 2, then factorial(3) returns 3 * 2 = 6, then factorial(4) returns 4 * 6 = 24, and finally factorial(5) returns 5 * 24 = 120.
  • Each frame's n is completely private to that call — the frames never see each other's local variables, which is exactly what makes recursion (and ordinary function calls in general) safe and predictable.

Without a base case that stops the recursion, this pattern would keep pushing frames until Python raises a RecursionError (Python's default recursion limit is 1000 frames).

Common Mistakes

Mistake 1: Using a mutable default argument

def add_item(item, cart=[]):
    cart.append(item)
    return cart

print(add_item("apple"))
print(add_item("banana"))
Output:
['apple']
['apple', 'banana']

This looks like each call should start with a fresh empty cart, but it does not. The default list [] is created once, when the function is defined, and every call that omits cart reuses that same list object — so items pile up across unrelated calls. The fix is to default to None and create a new list inside the function body:

def add_item(item, cart=None):
    if cart is None:
        cart = []
    cart.append(item)
    return cart

print(add_item("apple"))
print(add_item("banana"))
Output:
['apple']
['banana']

Now each call that omits cart gets its own brand-new list. This rule applies to any mutable default: lists, dicts, and sets should never be used directly as default argument values.

Mistake 2: Forgetting the return statement

def square(n):
    result = n * n

value = square(4)
print(value)
Output:
None

square computes result but never sends it back to the caller, so the function implicitly returns None — a very common source of confusing bugs, especially since no error is raised. The fix is simply to add the missing return:

def square(n):
    result = n * n
    return result

value = square(4)
print(value)
Output:
16

Best Practices

  • Name functions with verb phrases in snake_case that describe what they do, e.g. calculate_total, not data or func1.
  • Keep each function focused on a single task; if you need "and" to describe what it does, consider splitting it into smaller functions.
  • Write a docstring for any non-trivial function, describing its purpose, parameters, and return value.
  • Never use a mutable object (list, dict, set) as a default argument value — default to None and create the mutable object inside the function.
  • Prefer returning values over printing inside the function, so the caller decides what to do with the result.
  • Add type hints to parameters and return values (def total(prices: list[float]) -> float:) to make the interface self-documenting.
  • Use keyword arguments at call sites when a function takes several parameters, especially booleans, so the call is readable without checking the signature.
  • Avoid reading or writing global variables from inside a function; pass values in as parameters and get results out via return.
  • Reserve *args and **kwargs for cases where the function genuinely needs to accept a variable, open-ended number of arguments — not as a substitute for explicit parameters.

Practice Exercises

  • Exercise 1: Write a function is_palindrome(text) that returns True if text reads the same forwards and backwards, ignoring case, and False otherwise. For example, is_palindrome("Level") should return True.
  • Exercise 2: Write a function summarize_scores(*scores) that accepts any number of numeric scores using *args and returns a dictionary with keys "highest", "lowest", and "average".
  • Exercise 3: Write a recursive function fibonacci(n) that returns the nth Fibonacci number, where fibonacci(0) == 0 and fibonacci(1) == 1. Test it by printing fibonacci(10), which should print 55.

Summary

  • A function is created with def and only runs when it is called; the def statement itself just builds and names the function object.
  • Each call gets its own local scope; local variables disappear when the function returns.
  • Without an explicit return, a function returns None.
  • Default argument values are evaluated once, at definition time — never use a mutable default.
  • *args collects extra positional arguments into a tuple; **kwargs collects extra keyword arguments into a dict.
  • A bare * in the parameter list forces everything after it to be passed by keyword.
  • Functions are first-class objects in Python — they can be assigned, passed around, and stored just like any other value.
  • Favor small, single-purpose, well-named, documented functions with clear return values over side-effect-heavy ones.