On this page
Python functions are named, reusable blocks of code defined with def; they take arguments and return a value, or None if nothing is returned. They are the main tool for making code reusable and testable. Most of what goes wrong with them is not syntax, though — it is arguments.
Give a function basket=[] as a default, for example, and the second call returns the first call's shopping as well as its own. This guide is for Python learners who can write a basic def and want Python functions they can trust and reuse. It covers defaults, keyword-only arguments, *args and **kwargs, scope, type hints and the design habits that keep functions testable.
It follows Python operators in the series, and the official tutorial section on defining functions is a good companion.
How do you define a function in Python?
Use def, a name, parameters in parentheses, and an indented body:
def greet(name):
"""Return a greeting for the given name."""
return f"Hello, {name}"
greet("Ananya") # 'Hello, Ananya' - positional
greet(name="Ananya") # 'Hello, Ananya' - keyword
A function without a return statement still returns something, namely None:
def log(message):
print(message)
result = log("saved") # prints: saved
print(result) # None
Default values make arguments optional:
def connect(host, port=5432, timeout=30):
return host, port, timeout
connect("db.local") # ('db.local', 5432, 30)
connect("db.local", timeout=5) # ('db.local', 5432, 5) - skip port, name the one you want
Defaults must come after non-defaults in the signature.
Why does a list default argument remember old values?
Because default values are created once, when the function is defined, and then shared by every call. This is the bug every Python developer meets once:
def add_item(item, basket=[]): # BROKEN
basket.append(item)
return basket
add_item("apple") # ['apple']
add_item("bread") # ['apple', 'bread'] <- the previous call leaked
The default list is created once, when the function is defined, and reused by every call that does not supply its own. The tutorial flags this as an important warning about default argument values.
The fix:
def add_item(item, basket=None):
if basket is None:
basket = []
basket.append(item)
return basket
The same applies to dictionaries, sets and any other mutable object. By contrast, immutable defaults — numbers, strings, tuples, None — are safe. Our lists vs tuples comparison explains why.
What do * and / mean in a Python function signature?
A bare * makes every parameter after it keyword-only, and / makes every parameter before it positional-only. Anything after a bare * must be passed by name:
def export(data, *, format="csv", compress=False, overwrite=False):
return format, compress, overwrite
rows = [1, 2, 3]
export(rows, format="json", compress=True) # ('json', True, False)
export(rows, "json", True)
# TypeError: export() takes 1 positional argument but 3 were given
This is worth doing for boolean flags. For example, compare these two calls:
save(df, True, False, True) # what do these mean?
save(df, index=True, header=False, overwrite=True) # obvious
Positional-only arguments exist too, marked with /. They matter mainly for library authors who want the freedom to rename parameters later. The tutorial's special parameters section shows every combination, and this table summarises it:
| Signature part | Meaning | Example call |
|---|---|---|
a, / |
positional-only | f(1) |
b |
positional or keyword | f(1, 2) or f(1, b=2) |
*args |
extra positional values, as a tuple | f(1, 2, 3) |
c (after * or *args) |
keyword-only | f(1, 2, c=5) |
d=4 |
optional, with a default | omitted, or d=10 |
**kwargs |
extra keyword values, as a dict | f(1, 2, c=5, e=6) |
def f(a, /, b, *args, c, d=4, **kwargs):
return a, b, args, c, d, kwargs
f(1, 2, 3, c=5, e=6) # (1, 2, (3,), 5, 4, {'e': 6})
How do *args and **kwargs work?
*args gathers extra positional arguments into a tuple and **kwargs gathers extra keyword arguments into a dict:
def total(*numbers):
return sum(numbers)
total(1, 2, 3) # 6
def configure(**settings):
for key, value in settings.items():
print(f"{key} = {value}")
configure(debug=True, retries=3)
debug = True
retries = 3
Together, they usually appear when wrapping another function:
import functools, time, logging
def timed(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
start = time.perf_counter()
try:
return func(*args, **kwargs)
finally:
logging.info("%s took %.3fs", func.__name__, time.perf_counter() - start)
return wrapper
@timed
def train(model, data, epochs=10):
...
train.__name__ # 'train'
functools.wraps copies the original function's name and docstring onto the wrapper. Without it, every decorated function reports itself as wrapper in tracebacks and help output.
Unpacking also works at the call site:
args = [1, 2, 3]
total(*args) # 6
params = {"host": "db.local", "port": 5432}
connect(**params) # ('db.local', 5432, 30)
How does scope work in Python functions?
Python resolves names in the order local, enclosing, global, then built-in.
count = 0
def increment():
count += 1
increment()
# UnboundLocalError: cannot access local variable 'count'
# where it is not associated with a value
Assigning to a name anywhere in a function makes it local for the whole function, which is why this fails on a name that clearly exists. The Python FAQ explains why you get an UnboundLocalError when the variable has a value. A global count line inside the function would make it work, but that is usually a design smell.
Instead, prefer returning a value over mutating global state:
def increment(count):
return count + 1
count = increment(count) # 1
nonlocal does the equivalent for an enclosing function's scope, and is mostly used inside closures:
def make_counter():
n = 0
def step():
nonlocal n
n += 1
return n
return step
counter = make_counter()
counter() # 1
counter() # 2
Should you add type hints and docstrings to functions?
Yes, at least on public functions and module boundaries. Hints state the expected types for readers and checkers, and a docstring states arguments, return value and errors:
from collections.abc import Iterable
def summarise(values: Iterable[float], *, precision: int = 2) -> dict[str, float]:
"""
Compute basic summary statistics.
Args:
values: Numeric values to summarise. Must be non-empty.
precision: Decimal places to round each result to.
Returns:
A dict with keys 'mean', 'min' and 'max'.
Raises:
ValueError: If values is empty.
"""
vals = list(values)
if not vals:
raise ValueError("values must not be empty")
return {
"mean": round(sum(vals) / len(vals), precision),
"min": round(min(vals), precision),
"max": round(max(vals), precision),
}
summarise([2.5, 3.75, 4.125])
# {'mean': 3.46, 'min': 2.5, 'max': 4.12}
Notice that 4.125 rounds to 4.12, not 4.13. That is round() using round-half-to-even, which our numeric data types guide explains.
Hints are not enforced at runtime, so Python will happily pass a string. Instead, they exist for readers and for static checkers, following PEP 484, and they pay off most at module boundaries where the caller cannot see the implementation. Import abstract types such as Iterable from collections.abc, since the typing versions are deprecated aliases.
What makes a Python function easy to reuse and test?
A single job, a returned value instead of mutated state, early validation and a short signature. One job per function. If you are writing a comment saying "now we validate", that is a second function.
Return rather than mutate. A function that takes input and returns output is trivially testable. On the other hand, one that modifies a global or its argument in place is not.
# harder to reason about
def clean(df):
df.dropna(inplace=True)
df.columns = [c.lower() for c in df.columns]
# easier
def clean(df):
out = df.dropna().copy()
out.columns = [c.lower() for c in out.columns]
return out
Fail loudly. Validate at the top and raise a clear error rather than returning None and letting the failure surface three functions later.
Keep the signature small. More than four or five parameters usually means some of them belong together in an object or a dataclass.
Common mistakes with Python functions
- Mutable default arguments such as
def f(items=[]). - Forgetting
return, so the caller silently receivesNone. - Calling instead of passing a function, as in
sorted(data, key=len())rather thankey=len. - Writing boolean flags as positional arguments, which makes call sites unreadable.
- Decorators without
functools.wraps, which hide the real function name.
When a function is a one-line expression passed straight to another function, you often do not need def at all. Next in the series, Python lambda functions covers exactly that case.
The Sunday Growth Brief
One email a week: the best new comparisons, a fresh roadmap and the tech news worth your attention.
No spam. Unsubscribe in one click.
Related reading
Lambda functions covers the anonymous case. Exception handling covers failing well.
Frequently asked questions
Why is a mutable default argument dangerous?
Defaults are evaluated once, when the function is defined, not on each call. A default list or dict is therefore shared by every call, and mutations accumulate across them, so the second call sees data left by the first. Use None as the default and create the object inside the function body instead.
What is the difference between *args and **kwargs?
*args collects extra positional arguments into a tuple, while **kwargs collects extra keyword arguments into a dict. The names are only convention — the single and double asterisks are what matter. The same symbols also work in reverse at a call site, where they unpack a list or dict into separate arguments.
Should I add type hints to Python functions?
Yes at module boundaries and on public functions, where they document intent and let tools catch mistakes before runtime. They are optional inside small private helpers. Python does not enforce hints when the code runs, so a function annotated with int will still accept a string; a static checker such as mypy is what reports the mismatch.
How long should a function be?
Short enough that you can hold what it does in your head. There is no magic number of lines, but if you need to scroll, or you find yourself writing a comment that says 'now we do X', that is usually a second function asking to exist. Splitting it also makes each piece easier to name and test.
Written by
Ayushi Kulshreshta
AI Engineer
AI engineer building retrieval-augmented systems, previously a software developer at VDB Inc. and an R&D associate at Nokia.

