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 variablen = 5. - Since
5 > 1, Python must evaluate5 * factorial(4), which requires callingfactorial(4)— a second frame is pushed, with its own independentn = 4. - This repeats down to
factorial(1), which returns1directly 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, sofactorial(2)returns2 * 1 = 2, thenfactorial(3)returns3 * 2 = 6, thenfactorial(4)returns4 * 6 = 24, and finallyfactorial(5)returns5 * 24 = 120. - Each frame's
nis 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_casethat describe what they do, e.g.calculate_total, notdataorfunc1. - 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 toNoneand 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
*argsand**kwargsfor 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 returnsTrueiftextreads the same forwards and backwards, ignoring case, andFalseotherwise. For example,is_palindrome("Level")should returnTrue. - Exercise 2: Write a function
summarize_scores(*scores)that accepts any number of numeric scores using*argsand returns a dictionary with keys"highest","lowest", and"average". - Exercise 3: Write a recursive function
fibonacci(n)that returns thenth Fibonacci number, wherefibonacci(0) == 0andfibonacci(1) == 1. Test it by printingfibonacci(10), which should print55.
Summary
- A function is created with
defand only runs when it is called; thedefstatement 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 returnsNone. - Default argument values are evaluated once, at definition time — never use a mutable default.
*argscollects extra positional arguments into a tuple;**kwargscollects 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.
