Python – Functions

July 20, 20265 min readUpdated 8/20/2026

A function is a name for a block of code you want to run more than once — and, more importantly, a name for an idea. This post covers defining them, the four ways to pass arguments, and the default argument bug that is still the most common gotcha in the language.

Defining and calling

def format_money(amount):
    """Return the amount as '$1,250.00'."""
    return f"${amount:,.2f}"

print(format_money(1250))       # Output: $1,250.00
print(format_money(9650.5))     # Output: $9,650.50

def, a name, the parameters in brackets, a colon, and an indented body. The string on the first line is a docstring — not a comment, but part of the function, readable at runtime through help(format_money) and by every editor and documentation tool.

A function that reaches the end without a return returns None. That is not an error, and it is why x = my_list.sort() leaves you holding None.

Positional and keyword arguments

def transfer(source, target, amount):
    return f"{amount} from {source} to {target}"

print(transfer("checking", "savings", 250))                 # Output: 250 from checking to savings
print(transfer(amount=250, source="checking", target="savings"))
# Output: 250 from checking to savings

Positional arguments go in order. Keyword arguments name what they are, so the order stops mattering — and, far more usefully, the call site becomes readable. transfer(a, b, 250) makes the reader check which way the money went; source= and target= do not.

The rule of thumb: pass by keyword as soon as the meaning is not obvious from the value, and always for booleans. save(user, True) tells you nothing; save(user, overwrite=True) tells you everything.

Defaults

def statement(account, limit=10, newest_first=True):
    order = "newest" if newest_first else "oldest"
    return f"{limit} rows from {account}, {order} first"

print(statement("checking"))                   # Output: 10 rows from checking, newest first
print(statement("checking", 5))                # Output: 5 rows from checking, newest first
print(statement("checking", newest_first=False))
# Output: 10 rows from checking, oldest first

Parameters with defaults must come after those without. The third call skips limit entirely by naming the argument it wants — which is the reason to give a function several optional parameters rather than several variants.

The mutable default argument

This is the bug. A default value is evaluated once, when the function is defined — not each time it is called:

def add_item(item, basket=[]):
    basket.append(item)
    return basket

print(add_item("cheese"))     # ['cheese']
print(add_item("basil"))      # ['cheese', 'basil']  <- the SAME list

assert add_item("olives") == ["olives"], "the default list is shared between calls"

That assertion fails. There is exactly one list, created when Python read the def, and every call that relies on the default shares it — so the basket fills up across unrelated calls.

The fix is always the same. Default to None and build the real value inside:

def add_item(item, basket=None):
    if basket is None:
        basket = []
    basket.append(item)
    return basket

print(add_item("cheese"))     # Output: ['cheese']
print(add_item("basil"))      # Output: ['basil']

Treat it as a rule with no exceptions: never use a list, dict or set as a default value. Immutable defaults — numbers, strings, None, tuples — are safe, because sharing something that cannot change is harmless.

*args and **kwargs

When you do not know how many arguments there will be:

def total(*amounts, **options):
    result = sum(amounts)
    if options.get("with_fee"):
        result += 2.50
    return result

print(total(10, 20, 30))                    # Output: 60
print(total(10, 20, with_fee=True))         # Output: 32.5
print(total())                              # Output: 0

*amounts collects the leftover positional arguments into a tuple; **options collects the leftover keyword ones into a dict. The stars do the work — the names are convention, and *args/**kwargs is what you will see everywhere.

The same syntax unpacks in the other direction at a call site:

def transfer(source, target, amount):
    return f"{amount} from {source} to {target}"

args = ("checking", "savings")
opts = {"amount": 250}

print(transfer(*args, **opts))     # Output: 250 from checking to savings

Use these when writing a wrapper that must pass arguments through without knowing what they are — which is exactly what a decorator does. For an ordinary function, naming the parameters is better: *args destroys the documentation.

Arguments are passed by reference to the object

Reassigning a parameter does not affect the caller. Mutating one does:

def rename(name):
    name = "changed"          # rebinds the local name only

def add(items):
    items.append("changed")   # mutates the caller's list

n = "original"
xs = ["original"]
rename(n)
add(xs)

print(n)      # Output: original
print(xs)     # Output: ['original', 'changed']

This is the same "a variable is a name pointing at a value" rule from Data Types, seen from inside a function. It is neither pass by value nor pass by reference in the C++ sense — the reference is passed by value.

The practical consequence: a function that quietly modifies a list it was given is a function whose callers will eventually be surprised. Either return a new list, or make the mutation the obvious point of the function and say so in the name.

Type hints on a function

Hints record what a function expects and returns. Python does not enforce them at runtime — they are for readers and for checkers like mypy:

def label(amount: float) -> str:
    return f"Total: {amount}"

print(label(100.0))              # Output: Total: 100.0
print(label("not a number"))     # Output: Total: not a number

The second call violates the hint and runs anyway — proof that the annotation is documentation, not a guard. Nothing checks it unless you run a checker. A type checker would have refused that line before the program ran, which is the entire value proposition.

Note this also means a hint cannot save you from a genuine type error. Change the body to amount - 2.50 and the same call raises TypeError at that line, exactly as it would with no hints at all — the hint just told you in advance that it would.

Hints pay for themselves on the return type more than the parameters: -> float versus -> float | None is the difference between a value you can use and one you must check first. Dataclasses & Type Hints covers the syntax properly.

Scope, briefly

rate = 0.05                # module level

def apply(balance):
    bonus = balance * rate  # can READ the outer name
    return balance + bonus

print(apply(1000))         # Output: 1050.0
print("bonus" in dir())    # Output: False

A function can read names from the enclosing scope but assigning to one creates a new local instead of changing the outer. global and nonlocal override that, and needing either is usually a sign the value should be a parameter or a return value.

Next: Lambda Functions.