Functions are how you name behavior. Instead of writing the same five lines every time you need to validate user input, you write those five lines once, give them a name like validate_input, and call that name whenever you need it. That act of naming is the core purpose of a function and the starting point for everything else in this guide.
This is not a beginner glossary entry. If you have written Python for a few weeks and find yourself copying blocks of code between files or writing the same logic in multiple places, this is where that habit ends. We will cover what functions actually do well, where they trip people up, and the patterns that make a function easy to test, read, and reuse.
Defining and Calling a Function
The syntax is straightforward. A function definition starts with def, followed by the name, parentheses for parameters, and a colon. The body is indented.
def greet(name):
return f"Hello, {name}"
Calling the function is what actually runs the body:
message = greet("Alice")
print(message) # Hello, Alice
If you omit the return statement, the function returns None by default. This is a common source of confusion when you expect a modified object or a computed value but get nothing back.
Parameters and Arguments
Parameters are the names in the function definition. Arguments are the values you pass when calling. Python separates these cleanly and gives you several ways to pass them.
Positional arguments are the default. They map to parameters by position:
def power(base, exponent):
return base ** exponent
result = power(2, 3) # base=2, exponent=3, result=8
Keyword arguments let you name the parameter explicitly at the call site, which makes the intent clear and allows you to reorder:
result = power(exponent=3, base=2) # same result, unambiguous
Default parameter values let callers omit arguments that have sensible defaults:
def power(base, exponent=2):
return base ** exponent
print(power(4)) # 16, uses default exponent=2
print(power(4, 3)) # 64, overrides default
One rule that trips up newcomers: never use a mutable object like a list or dict as a default argument. Python evaluates defaults once at definition time, not at call time. If you mutate the default, that mutation persists across calls:
# Wrong
def add_item(item, items=[]):
items.append(item)
return items
print(add_item("first")) # ['first']
print(add_item("second")) # ['first', 'second'] — bug!
# Correct
def add_item(item, items=None):
if items is None:
items = []
items.append(item)
return items
print(add_item("first")) # ['first']
print(add_item("second")) # ['second']
Returning Values
A Python function can return any object. Returning multiple values is done by packing them into a tuple, which the caller can unpack:
def divide_with_remainder(a, b):
quotient = a // b
remainder = a % b
return quotient, remainder
q, r = divide_with_remainder(17, 5)
print(q, r) # 3 2
The return statement ends the function immediately. Any code after a return inside the function body never runs. This is useful for early exits:
def process(data):
if not data:
return None # nothing to process
# expensive operations here
return transformed
Variable Scope: Local, Global, Nonlocal
Variables defined inside a function are local to that function. They do not exist outside the function body, and they shadow any variables with the same name in the outer scope:
def scope_demo():
x = 10 # local variable
print(x) # 10
scope_demo()
print(x) # NameError: x is not defined
To modify a global variable from inside a function, declare it with global:
counter = 0
def increment():
global counter
counter += 1
increment()
print(counter) # 1
Global state makes testing difficult. If a function reads or modifies global variables, you cannot test it in isolation. Passing values as parameters and returning results is almost always the better design.
nonlocal is used when you have a nested function and need to modify a variable from the enclosing scope:
def outer():
count = 0
def inner():
nonlocal count
count += 1
return count
return inner
counter_fn = outer()
print(counter_fn()) # 1
print(counter_fn()) # 2
*args and **kwargs: Flexible Parameters
The *args syntax lets a function accept any number of positional arguments. The function receives them as a tuple:
def sum_all(*numbers):
total = 0
for n in numbers:
total += n
return total
print(sum_all(1, 2, 3)) # 6
print(sum_all(10, 20, 30, 40)) # 100
**kwargs does the same for keyword arguments, packing them into a dictionary:
def print_config(**settings):
for key, value in settings.items():
print(f"{key} = {value}")
print_config(host="localhost", port=8080, debug=True)
You can combine all three in one function signature: positional, *args, and **kwargs. Python enforces the order: positional parameters first, then *args, then keyword-only parameters, then **kwargs.
Functions as First-Class Objects
In Python, functions are objects. You can assign them to variables, pass them as arguments to other functions, return them from functions, and store them in data structures. This is not a special feature — it is built into the language design.
Passing a function as an argument is the basis for callbacks, middleware, and strategy patterns:
def apply_twice(fn, value):
return fn(fn(value))
def add_three(x):
return x + 3
print(apply_twice(add_three, 0)) # 6: (0+3)+3
Returning a function from a function is how decorators and factories work:
def make_multiplier(factor):
def multiply(x):
return x * factor
return multiply
double = make_multiplier(2)
triple = make_multiplier(3)
print(double(5)) # 10
print(triple(5)) # 15
make_multiplier returns a function that closes over the factor variable. That is a closure — which brings us to the next section.
Closures and Why They Matter
A closure is a function that remembers values from the scope where it was defined, even after that scope has finished executing. The factor in the multiply function is captured from make_multiplier‘s local scope and stays alive as long as multiply exists.
Closures show up constantly in real code. They are how you attach behavior to data without using a full class:
def make_counter():
count = 0
def counter():
nonlocal count
count += 1
return count
return counter
click_counter = make_counter()
print(click_counter()) # 1
print(click_counter()) # 2
print(click_counter()) # 3
The inner counter function is a closure that holds onto count. Each call to click_counter() increments that captured value. You get stateful behavior without a class or any mutable global.
Lambda Functions
A lambda is an anonymous single-expression function. It is useful when you need a small throwaway function and writing a full def block feels like overkill:
numbers = [3, 1, 4, 1, 5, 9, 2, 6]
sorted_by_mod_3 = sorted(numbers, key=lambda x: x % 3)
print(sorted_by_mod_3) # [3, 9, 6, 1, 1, 4, 5, 2]
The same logic as a named function:
def mod_3(x):
return x % 3
sorted_by_mod_3 = sorted(numbers, key=mod_3)
Use lambdas when the function is genuinely simple and used in one place. If the expression grows past a couple of lines or is used in multiple places, write a named function instead. A lambda can only contain an expression — not statements like assignment.
Type Hints and Docstrings
Type hints make it clear what a function expects and what it returns. They do not enforce anything at runtime by default, but they make code review faster, IDE support better, and bugs easier to catch with tools like mypy:
def greet(name: str, times: int = 1) -> str:
return (f"Hello, {name}! " * times).strip()
print(greet("Alice")) # Hello, Alice!
print(greet("Bob", 3)) # Hello, Bob! Hello, Bob! Hello, Bob!
Docstrings document what a function does. The minimal useful docstring covers the purpose, parameters, and return value:
def divide_with_remainder(a: int, b: int) -> tuple[int, int]:
"""Return the quotient and remainder of a divided by b.
Args:
a: The dividend.
b: The divisor (must be non-zero).
Returns:
A tuple of (quotient, remainder).
"""
if b == 0:
raise ValueError("divisor cannot be zero")
return a // b, a % b
A docstring that just says “This function does something” is not useful. A docstring that tells you what it returns, what exceptions it raises, and what the parameters mean is something you will thank yourself for six months later.
Frequently Asked Questions
Should I use global variables inside functions?
Almost never. Global state makes functions harder to test, harder to reason about, and a common source of subtle bugs when multiple parts of the code modify the same variable. Pass values as parameters and return results instead. If you find yourself using global in production code, it is a signal to reconsider the design.
What is the difference between *args and a list parameter?
A list parameter requires the caller to pack arguments into a list before calling. *args lets the caller pass any number of positional arguments naturally, and the function receives them as a tuple. Both work, but *args is more readable when the function genuinely accepts a variable number of values:
# Less natural for the caller
def sum_all(numbers):
return sum(numbers)
sum_all([1, 2, 3])
# More natural
def sum_all(*numbers):
return sum(numbers)
sum_all(1, 2, 3)
When should I use a lambda instead of a named function?
Use a lambda when you need a small function for one specific use and defining it as a named function would add noise. sorted(my_list, key=lambda x: x['score']) is cleaner than defining a separate get_score function just for that one sort. If the lambda logic is complex enough that it needs a comment, use a named function instead.
Can a function return multiple values?
A function returns exactly one object. When you write return a, b, Python packages those into a tuple under the hood. The caller can then unpack them into separate variables, which makes it look like multiple return values. This is a common pattern and entirely intentional in Python.
How do closures differ from classes for maintaining state?
Classes and closures can both maintain state. Classes are more explicit and are the right tool when the state is complex or you need multiple methods. Closures are lighter weight and useful when you have a single piece of state or need a simple callable. Once a closure grows to the point where you are managing several captured variables, it is usually clearer to switch to a class.
