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.