Skip to content
1stepGrowLearnCompareGrow

Python Functions: Writing User-Defined Functions Worth Reusing

How to write Python functions: arguments, defaults, *args and **kwargs, scope, type hints and docstrings, plus the mutable default bug everyone hits once.

Ayushi Kulshreshta

AI Engineer

9 min readUpdated
Share
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:

Python
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:

Python
def log(message):
    print(message)

result = log("saved")    # prints: saved
print(result)            # None

Default values make arguments optional:

Python
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:

Python
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:

Python
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:

Python
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:

Python
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)
Python
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:

Python
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)
text
debug = True
retries = 3

Together, they usually appear when wrapping another function:

Python
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:

Python
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.

Python
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:

Python
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:

Python
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:

Python
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.

Python
# 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 receives None.
  • Calling instead of passing a function, as in sorted(data, key=len()) rather than key=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.

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.

Retrieval-Augmented GenerationLangChainPythonMachine learning

Related reading

Free weekly newsletter

Get the shortlist before everyone else

Every Sunday we send one email with the week’s sharpest course comparison, a career roadmap worth stealing, and the tech news and hiring signals we’re watching.

  • No fluff, ever
  • Unsubscribe anytime
  • 5-minute read

We never share your address. One click to leave.