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

- **Author:** Ayushi Kulshreshta — AI Engineer (https://www.1stepgrow.com/authors/ayushi-kulshreshta/)
- **Published:** Jul 14, 2026 · **Updated:** Sep 17, 2026
- **Topic:** Python & Programming · **Format:** Guide · **Read time:** 9 min
- **Canonical URL:** https://www.1stepgrow.com/articles/python-functions/

## Key takeaways

- Never use a mutable object as a default argument — it is created once and shared across every call.
- Keyword-only arguments (after *) make call sites self-documenting for boolean flags.
- A function that does one thing and returns a value is easier to test than one that mutates state.
- Type hints are documentation the tooling can check; add them at module boundaries at minimum.
- Assigning to a name anywhere inside a function makes it local for the whole function, which causes UnboundLocalError.

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](https://www.1stepgrow.com/articles/python-operators) in the series, and the official tutorial section on [defining functions](https://docs.python.org/3/tutorial/controlflow.html#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](https://docs.python.org/3/tutorial/controlflow.html#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](https://www.1stepgrow.com/articles/lists-vs-tuples-python) 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](https://docs.python.org/3/tutorial/controlflow.html#special-parameters) 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`](https://docs.python.org/3/library/functools.html#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](https://docs.python.org/3/faq/programming.html#why-am-i-getting-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](https://www.1stepgrow.com/articles/python-numeric-data-types) 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](https://peps.python.org/pep-0484/), 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](https://www.1stepgrow.com/articles/python-lambda-functions) covers exactly that case.



## Related reading

[Lambda functions](https://www.1stepgrow.com/articles/python-lambda-functions) covers the anonymous case. [Exception handling](https://www.1stepgrow.com/articles/python-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.

---
_Source: 1stepGrow (https://www.1stepgrow.com/articles/python-functions/). Cite with the title, "1stepGrow" and a link._
