Python Atlas
Foundations

Functions

Design reusable functions with clear inputs, outputs, scope, documentation, and errors.

A function gives a behavior a name and defines an interface between callers and an implementation. Good functions reduce repetition, isolate decisions, and make programs easier to test.

Define and call a function

def calculate_area(width, height):
    area = width * height
    return area


room_area = calculate_area(4.5, 3)
print(room_area)  # 13.5

width and height are parameters. 4.5 and 3 are arguments. A call evaluates arguments, binds them to parameters, executes the body, and produces the returned value.

If execution reaches the end without return, the function returns None.

Return values, do not only print

def format_total(amount):
    return f"${amount:,.2f}"


label = format_total(1250)
print(label)

Returning separates computation from presentation. A caller can print, log, store, or test the result. Use print inside a function only when producing output is genuinely its responsibility.

A function can return multiple values as one tuple:

def minimum_and_maximum(values):
    return min(values), max(values)


low, high = minimum_and_maximum([8, 3, 11])

Reusable design

A focused function usually:

  • does one coherent job;
  • has a verb-based name that states intent;
  • receives dependencies as parameters rather than relying on hidden global state;
  • returns a useful value;
  • handles or clearly rejects invalid input.
def percentage(part, whole):
    if whole == 0:
        raise ValueError("whole must not be zero")
    return part / whole * 100

Raise an exception when the function cannot honor its contract. Do not return an unrelated sentinel such as -1 unless that value is explicitly part of the interface.

Type hints

Type hints document expected values and help static tools; Python does not enforce them at runtime:

def average(values: list[float]) -> float:
    if not values:
        raise ValueError("values must not be empty")
    return sum(values) / len(values)

When a function only needs iteration, a broader annotation such as Iterable[float] from collections.abc may be more flexible. Start with concrete types while learning, then widen interfaces when a real use case requires it.

Docstrings

Put a string literal immediately inside a public or non-obvious function:

def clamp(value: float, minimum: float, maximum: float) -> float:
    """Return value limited to the inclusive minimum-to-maximum range."""
    if minimum > maximum:
        raise ValueError("minimum must not exceed maximum")
    return max(minimum, min(value, maximum))

Document purpose, important constraints, exceptional behavior, and units. Avoid restating every line of obvious code.

Scope and name resolution

Names assigned inside a function are local:

tax_rate = 0.2


def total_with_tax(subtotal):
    total = subtotal * (1 + tax_rate)
    return total

The function reads the module-level tax_rate, but total exists only during the call.

Avoid modifying globals from functions:

def add_task(tasks, task):
    return [*tasks, task]

Passing state explicitly makes behavior easier to understand and test. global and nonlocal exist for specialized cases, but they are not substitutes for clear data flow.

Mutation versus new values

Be explicit about whether a function mutates an argument:

def normalized_names(names):
    return [name.strip().casefold() for name in names]

This version returns a new list and leaves the caller's list unchanged. Mutation can be valid for performance or an intentionally stateful API, but make it clear in the name and documentation.

Early returns

Guard clauses keep the main path shallow:

def shipping_cost(total, is_member):
    if total < 0:
        raise ValueError("total cannot be negative")
    if is_member or total >= 50:
        return 0
    return 5

This is often clearer than deeply nested if/else blocks.

Functions as values

Functions can be assigned, passed, and returned:

def normalize(text):
    return text.strip().casefold()


names = ["  Grace", "ada  "]
print(sorted(names, key=normalize))

Pass the function itself as key=normalize; writing normalize() would call it immediately without the required argument.

Use a lambda for a short, local expression:

records = [("Ada", 36), ("Grace", 37)]
by_age = sorted(records, key=lambda record: record[1])

Use def when logic needs a name, documentation, multiple statements, or reuse.

Common mistakes

  • Forgetting return and receiving None.
  • Printing a result inside a function when callers need to use it.
  • Using mutable default arguments; the next lesson shows the safe pattern.
  • Depending on or mutating global state unnecessarily.
  • Confusing return with break; return exits the entire function.
  • Catching every exception and hiding programming errors.
  • Writing one large function that mixes input, processing, and output.

Decision guidance

  • Extract a function when logic repeats, names a meaningful operation, or deserves isolated testing.
  • Pass required information in and return results out.
  • Raise a specific exception for invalid contracts; handle expected exceptions at the layer that can make a useful decision.
  • Prefer pure functions for transformations, while allowing explicit mutation where it models the intended API.
  • Use def by default and lambdas only for small callback expressions.

Practice

  1. Write is_even(number) returning a boolean without an if statement.
  2. Write safe_average(values) that rejects an empty list with ValueError.
  3. Refactor a temperature conversion script into input-free conversion functions.
  4. Write a documented clamp function and test normal, boundary, and invalid-range cases.

LEARNING RECORD

Finish this lesson

Mark it complete when you can explain the main decision without looking.

KNOWLEDGE CHECK

Functions

1What does a Python function return if execution reaches its end without `return`?
2Why should `sorted(names, key=normalize)` receive `normalize` without parentheses?