Python Primer·Chapter 7

Functions That Behave

Arguments, defaults, scope and return values. Includes the mutable default trap, which catches every Python programmer exactly once, and the docstring habit that makes code survivable six months later.

A function is a named piece of work you can run more than once. That much is familiar from any language. Python has a few specific behaviours around arguments that are worth learning properly, and one genuine trap.

The Shape of a Function

def standardise(name):
    """Return a name with surrounding whitespace removed and lowercased."""
    return name.strip().lower()

standardise("  Delhi  ")      # 'delhi'

Three parts: def, a name, and parentheses holding the parameters. The indented block is the body. return sends a value back and ends the function immediately.

A function with no return returns None. That is not an error, and it is why result = my_list.sort() gives you None rather than a sorted list.

def log(message):
    print(message)          # no return

x = log("hello")            # x is None

Arguments

Arguments can be passed by position or by name.

def describe(city, population, tier=1):
    return f"{city}: {population}m, tier {tier}"

describe('Delhi', 33.8)                    # positional
describe('Delhi', 33.8, 2)                 # positional, overriding the default
describe(city='Delhi', population=33.8)    # by keyword
describe(population=33.8, city='Delhi')    # keyword, any order

Parameters with defaults must come after those without. That is a syntax rule, not a style preference.

Use keyword arguments when calling anything with more than two parameters. Compare:

train(0.01, 100, True, False)                              # what are these?
train(learning_rate=0.01, epochs=100, shuffle=True,
      verbose=False)                                       # obvious

The second version survives someone reordering the parameters, and it survives you reading it next year.

Forcing keyword arguments

A bare * in the signature means everything after it must be passed by name:

def split_data(df, *, test_size=0.2, random_state=None):
    ...

split_data(df, 0.3)                    # TypeError
split_data(df, test_size=0.3)          # fine

This is worth doing for any function with several optional settings. It stops callers from passing a positional argument into the wrong slot, and it lets you reorder the parameters later without breaking anyone.

Collecting extra arguments

def summarise(*values, **options):
    print(values)      # a tuple of extra positional arguments
    print(options)     # a dict of extra keyword arguments

summarise(1, 2, 3, verbose=True, colour='blue')
# (1, 2, 3)
# {'verbose': True, 'colour': 'blue'}

The names args and kwargs are conventional but the stars are what matter. Use these sparingly. A function that accepts anything documents nothing, and your editor cannot help the caller.

The Mutable Default Trap

This is the one genuine gotcha in Python function definitions. Every Python programmer meets it once and remembers it forever.

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

add_item('apple')      # ['apple']
add_item('pear')       # ['apple', 'pear']   ← where did the apple come from?
add_item('plum')       # ['apple', 'pear', 'plum']

The default value is created once, when the function is defined, not each time it is called. Every call that relies on the default shares the same list.

flowchart TB
    D["<b>def</b> runs once<br/>creates one empty list"] --> L["the default list object"]
    C1["call 1"] --> L
    C2["call 2"] --> L
    C3["call 3"] --> L
    L --> R["all three calls append<br/>to the SAME list"]
    style L fill:#f7e8e8,stroke:#c02020
    style R fill:#f7e8e8,stroke:#c02020

The fix is always the same. Use None as the default and create the object inside:

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

The rule: never use a list, dict, or set as a default value. Immutable defaults such as numbers, strings, True and None are all safe, because there is nothing to accumulate.

Arguments Are Passed by Reference

Following from the previous chapter: a function receives labels pointing at the caller’s objects. If it modifies a mutable argument, the caller sees the change.

def add_column(df):
    df['new'] = 1            # modifies the CALLER'S DataFrame
    return df

def add_column_safely(df):
    df = df.copy()           # work on our own copy
    df['new'] = 1
    return df

Neither is wrong. What is wrong is not deciding, and not saying which one you chose. If a function modifies its input, say so in the name or the docstring. Silent mutation is how “the numbers changed and nobody touched them” happens.

Returning Several Values

def summarise(values):
    return min(values), max(values), sum(values) / len(values)

lo, hi, avg = summarise([1, 2, 3, 4])

That is one tuple being unpacked, not three return values. Once you are returning more than three things, return a dictionary or a small dataclass instead, so that callers do not have to remember the order.

from dataclasses import dataclass

@dataclass
class Summary:
    minimum: float
    maximum: float
    mean: float

def summarise(values):
    return Summary(min(values), max(values), sum(values) / len(values))

s = summarise([1, 2, 3, 4])
s.mean                                  # readable, and order-independent

Scope

Names created inside a function are local to it and vanish when it returns.

def f():
    x = 10          # local
    return x

f()
print(x)            # NameError — x does not exist out here

A function can read names from the surrounding module, which is how it sees imports and constants. It cannot reassign them without saying so explicitly, and needing to do that is almost always a sign you should be passing a value in and returning one out instead.

TAX_RATE = 0.2                # module-level constant, fine to read

def with_tax(amount):
    return amount * (1 + TAX_RATE)     # reading is fine

The practical guideline: a function should depend on its arguments and nothing else. Functions that read mutable global state are the hardest kind to test and to reason about.

Docstrings

A string as the first thing in a function body becomes its documentation, retrievable with help() and shown by your editor.

def clean_names(series, lowercase=True):
    """Strip whitespace from a text column and optionally lowercase it.

    Args:
        series: A pandas Series of strings.
        lowercase: Whether to lowercase the result.

    Returns:
        A new Series. The input is not modified.
    """
    out = series.str.strip()
    return out.str.lower() if lowercase else out

For data work, the sentence that earns its place most often is the last one: whether the input is modified. That is the fact a future reader most needs and can least easily determine by looking.

Type Hints

Optional annotations describing what goes in and comes out. Python does not enforce them at runtime; your editor and tools like mypy use them to catch mistakes before you run anything.

def standardise(name: str) -> str:
    return name.strip().lower()

def top_n(values: list[float], n: int = 5) -> list[float]:
    return sorted(values, reverse=True)[:n]

For data work, hints are most useful on the boundaries: functions other people call, and functions that take several arguments of the same type where the order is easy to get wrong.

They are less useful inside exploratory analysis code, where the shapes change constantly. Annotate the parts that are stable. Skip the parts that are not. Half-annotated code is fine and common.

import pandas as pd

def load_customers(path: str) -> pd.DataFrame:
    ...

Small Functions Beat Comments

The most useful refactoring in data work is pulling a confusing block out into a named function. The name replaces the comment, and the code becomes testable.

# Before
df = df[df.age.notna()]
df = df[(df.age >= 18) & (df.age <= 100)]
df['age_band'] = pd.cut(df.age, [18, 30, 50, 100])

# After
def clean_age(df):
    """Drop missing ages, keep plausible ones, add a band."""
    df = df[df.age.notna()]
    df = df[df.age.between(18, 100)]
    return df.assign(age_band=pd.cut(df.age, [18, 30, 50, 100]))

df = clean_age(df)

The second version can be tested with three rows of made-up data, and it can be reused next month. The first version can only be re-read.

Lambdas

A tiny unnamed function, written inline.

sorted(cities, key=lambda c: c['population'])
df['log_pop'] = df.population.apply(lambda x: math.log(x))

They exist for exactly this: passing a one-expression function to something that expects a function. If you find yourself wanting a lambda with a conditional in it, or wanting to assign a lambda to a name, write a def instead. A named function gives you a docstring, a traceback that says something useful, and somewhere to put a test.

square = lambda x: x ** 2       # don't
def square(x): return x ** 2    # do

What Carries Forward

Never use a mutable default. def f(x, items=None) and create the list inside.

Use keyword arguments for anything with more than two parameters, and consider forcing them with a bare *.

Decide whether your function mutates its input, and say so. This single line of documentation prevents a category of bug that is very hard to trace.

Pull confusing blocks into named functions. The name does the work a comment would have done, and the result can be tested.

Next: iterators and generators, which are how you process a file larger than your computer’s memory.