Skip to content

Functions

As your programs grow, you will write the same logic in more than one place. Functions let you write logic once, name it, and use it everywhere. Fix it in one place and every call gets the fix automatically.

Functions are the primary unit of code reuse and abstraction. They encapsulate a behaviour, give it a name, define a clear interface (parameters and return value), and make it callable from anywhere. Well-named functions also serve as documentation: validate_email() tells you what a block of code does without reading it.

In Python, def creates a function object and binds it to a name in the current scope. Functions are first-class objects: they can be assigned to variables, stored in collections, passed as arguments, and returned from other functions. Closures capture free variables from the enclosing scope. Understanding this makes higher-order programming natural.

python
def greet(name):
    return f"Hello, {name}!"

print(greet("Alice"))   # "Hello, Alice!"
print(greet("Bob"))     # "Hello, Bob!"

Write it once, use it everywhere, fix it in one place.

Defining a function

The def keyword starts a function definition, followed by the name, parentheses, a colon, and an indented body. A function does nothing until you call it. Define it with def, then call it by name with ().

def is a statement that creates a function object and binds it to the given name in the current scope. The body is not executed at definition time; it runs only when the function is called. Functions without a return statement implicitly return None.

def is a compound statement that compiles the function body to a code object and binds a new function object to the name in the current namespace. The function object stores a reference to its code object, its default argument values, and a reference to the enclosing scope (for closures). The body executes only on invocation.

python
def say_hello():
    print("Hello!")

say_hello()   # call the function

Parameters and arguments

Parameters are the inputs your function expects. List them inside the parentheses. When you call the function, the values you pass are matched to the parameters in order.

Parameters define the interface of a function. Arguments are the concrete values passed at call time. Positional arguments are matched by position; keyword arguments are matched by name. Default values make parameters optional.

Python has four parameter kinds: positional-or-keyword (the default), keyword-only (after *), positional-only (before /, Python 3.8+), and variadic (*args, **kwargs). At call time, positional arguments bind left to right; keyword arguments bind by name. Conflicts raise TypeError. Default values are evaluated once at function definition time, not on each call.

python
def greet(name, greeting):
    print(f"{greeting}, {name}!")

greet("Alice", "Hello")    # "Hello, Alice!"
greet("Bob", "Hi")         # "Hi, Bob!"

Parameters vs arguments

Parameter is the name in the function definition. Argument is the actual value you pass when you call the function. In practice, people use the words interchangeably. Just be aware of the distinction when reading docs.

Default values

You can give parameters a default value. If the caller does not provide that argument, the default is used. Parameters with defaults must come after parameters without defaults.

Default values make parameters optional. They are evaluated once at definition time, not on each call. This matters for mutable defaults: def f(items=[]) shares the same list across all calls. The fix is to use None as the default and create the list inside the function body.

Default values are stored on the function object as f.__defaults__ (positional) and f.__kwdefaults__ (keyword-only). They are evaluated once when def executes, not per call. The mutable default trap (def f(x=[])) is a classic Python gotcha: the list is created once and mutated in place across calls. The idiomatic fix: def f(x=None): if x is None: x = [].

python
def greet(name, greeting="Hello"):
    print(f"{greeting}, {name}!")

greet("Alice")           # "Hello, Alice!"
greet("Alice", "Hi")     # "Hi, Alice!"

Parameters with defaults must come after parameters without defaults.

Keyword arguments

When calling a function, you can name the arguments. This makes calls readable, especially for functions with many parameters, and lets you pass them in any order.

Keyword arguments make function calls self-documenting. You can mix positional and keyword: positional arguments must come first. For functions with boolean flags or many parameters of similar types, keyword arguments prevent silent mistakes from passing arguments in the wrong order.

Keyword arguments bind by name, not position. Positional arguments must precede keyword arguments at call time. Passing the same argument both positionally and by name raises TypeError. To enforce keyword-only arguments, place them after a bare * in the parameter list: def f(a, *, b) makes b keyword-only.

python
def describe_player(name, score, level):
    print(f"{name} | Score: {score} | Level: {level}")

describe_player("Alice", 87, 5)                        # positional
describe_player(name="Alice", level=5, score=87)       # keyword, any order
describe_player("Alice", level=5, score=87)            # mix: positional first

Return values

return sends a value back to the caller. Without return, a function gives back None. Once return runs, the function exits immediately. Any code after it in that block is skipped.

return exits the function and passes a value to the caller. A function without an explicit return implicitly returns None. return can appear anywhere in the function body and may be used multiple times; the first one reached ends the function. This makes early returns useful for guard clauses.

return emits a RETURN_VALUE bytecode that pops the top of the stack and hands it to the caller's frame. A function that falls off the end implicitly returns None. Multiple return statements are fine and often cleaner than a single return with complex conditions (the "early return" pattern). return inside a try block still executes any associated finally clause.

python
def add(a, b):
    return a + b

result = add(3, 4)   # result = 7
print(result)

return also exits the function immediately. Any code after it in that block does not run.

Returning multiple values

Python lets you return multiple values by separating them with commas. The caller receives them as a tuple and can unpack them into separate names in one line.

Returning multiple values with a comma packs them into a tuple. The caller unpacks with matching names. This is idiomatic Python for functions that naturally produce more than one result. It is not a special feature; it is tuple packing and unpacking.

return a, b packs the values into a tuple via implicit packing. The caller unpacks with x, y = f(), which calls __iter__ on the returned tuple. For clarity in type hints, annotate the return type as tuple[int, str] or use a named tuple. Returning a plain tuple is fine for small counts; for more than two or three values, consider a named tuple or dataclass.

python
def min_max(numbers):
    return min(numbers), max(numbers)

low, high = min_max([3, 7, 1, 9, 4])
print(low, high)   # 1 9

The low, high = ... syntax is unpacking: Python assigns each returned value to the corresponding name.

Scope

Variables created inside a function exist only inside that function. You cannot see them from outside. Variables defined outside all functions are visible everywhere, but you cannot change them from inside a function without an explicit declaration.

Python has local scope (inside a function), enclosing scope (in an outer function for nested functions), global scope (the module level), and built-in scope. Name lookup follows the LEGB rule in that order. Local variables shadow outer ones with the same name. global declares that a name refers to the module-level binding.

Python's LEGB name resolution: Local, Enclosing (closure), Global (module), Built-in. Each def creates a new local namespace. Reading a global variable works automatically; writing it requires global x to avoid creating a local shadow. nonlocal x accesses the nearest enclosing (non-global) scope, enabling closures to mutate captured variables. Overusing global makes code hard to reason about; prefer parameters and return values.

python
def calculate():
    result = 42   # local to this function
    return result

calculate()
print(result)   # NameError, result doesn't exist out here
python
count = 0

def increment():
    global count    # declare you want to modify the global
    count += 1

increment()
print(count)   # 1

Using global should be a last resort. It makes code harder to reason about. Prefer passing values in and returning them out.

*args and **kwargs

Sometimes you do not know how many arguments a function will receive. *args collects any number of positional arguments into a tuple. **kwargs collects any number of keyword arguments into a dictionary. The names args and kwargs are conventions; the stars are what matter.

*args collects excess positional arguments into a tuple. **kwargs collects excess keyword arguments into a dict. Both can be combined with regular parameters. Regular parameters come first, then *args, then keyword-only parameters, then **kwargs. They are useful for wrapper functions that pass arguments through to another function.

*args creates a tuple from remaining positional arguments. **kwargs creates a dict from remaining keyword arguments. Parameter order: positional-or-keyword, *args, keyword-only, **kwargs. At call sites, *iterable unpacks positional arguments and **mapping unpacks keyword arguments. These are symmetrical: * in a signature collects; * at a call site unpacks.

python
def total(*args):
    return sum(args)

total(1, 2, 3)          # 6
total(1, 2, 3, 4, 5)   # 15
python
def display(**kwargs):
    for key, value in kwargs.items():
        print(f"{key}: {value}")

display(name="Alice", score=87, level=5)

You can mix them with regular parameters. Regular parameters come first:

python
def describe(title, *tags, **metadata):
    print(f"{title} | tags: {tags} | meta: {metadata}")

describe("Python intro", "beginner", "python", author="Alice", year=2024)

Docstrings

A docstring is a string at the top of a function that describes what it does. Python editors and tools use it to show help when you hover over a function call. Use triple quotes, and write one line for simple functions.

Docstrings are stored as f.__doc__ and displayed by help(). The convention is one summary line, optionally followed by a blank line and more detail. For public-facing functions, a docstring is documentation that tools can surface; it is not optional on anything called from multiple places.

Docstrings are string literals placed as the first statement of a function, class, or module body. They are stored as __doc__ on the object. PEP 257 defines conventions. Tools like Sphinx, pydoc, and IDEs all rely on __doc__. For type information in docstrings, use Google, NumPy, or Sphinx style; type hints in the signature are preferred over type annotations in the docstring for modern code.

python
def normalise(value, min_val, max_val):
    """Scale a value to the 0-1 range given the known min and max."""
    return (value - min_val) / (max_val - min_val)
python
def build_url(base, version, resource, *, secure=True):
    """
    Build an API endpoint URL.

    Returns a fully-qualified URL string. If secure is False,
    the URL will use http instead of https.
    """
    scheme = "https" if secure else "http"
    base   = base.replace("https://", "").replace("http://", "")
    return f"{scheme}://{base}/{version}/{resource}"

Write a docstring for any function that is not obviously self-explanatory from its name and signature.

Type hints

Type hints let you annotate what types a function expects and returns. Python does not enforce them at runtime, but editors use them to catch mistakes before you run anything. The -> before the colon specifies the return type.

Type hints are documentation that tools verify. Editors and type checkers (mypy, pyright) use them to catch type mismatches before runtime. They have no runtime effect in standard Python. -> None is the correct annotation for functions with no return value. For generic containers, use list[int], dict[str, int] (Python 3.9+).

Type hints are processed by typing.get_type_hints() at runtime but have no direct effect on execution. Static type checkers analyse them before runtime. PEP 484 introduced the annotation system; PEP 585 allowed built-in generics like list[int] without typing imports in Python 3.9+. -> None on a function signals both "returns nothing" and "should not be used in an expression context". For complex types, typing.Protocol, typing.TypeVar, and typing.overload give full static typing power.

python
def greet(name: str, score: int) -> str:
    return f"{name} scored {score}"
python
def log(message: str) -> None:
    print(f"[LOG] {message}")
python
def top_scores(scores: list[int], n: int) -> list[int]:
    return sorted(scores, reverse=True)[:n]

Type hints are optional but valuable on any function that will be called from multiple places. They are documentation that tools can verify.

Functions as values

Functions in Python are values, just like strings or numbers. You can assign them to variables and pass them to other functions. This is how sorted() accepts a key= function.

Functions are first-class objects: they have a type (function), can be stored in variables and collections, and can be passed as arguments or returned as values. This is the foundation of higher-order functions like sorted(key=...), map(), and filter().

Functions are objects of type function with attributes: __name__, __doc__, __annotations__, __defaults__, __code__, and __closure__. They are passed by reference, not copied. Returning a function from a function creates a closure if the inner function references variables from the outer scope: those variables are stored in f.__closure__.

python
def double(x):
    return x * 2

def apply(func, value):
    return func(value)

apply(double, 5)   # 10

Passing functions as arguments shows up constantly with sorted(), map(), and filter(). You will also see it in the Lambda, comprehensions, and zip chapter.

In practice

Two functions that work together: letter_grade converts a score to a letter, and summarise calls it for every score in a list:

python
def letter_grade(score: int) -> str:
    if score >= 90:
        return "A"
    elif score >= 80:
        return "B"
    elif score >= 70:
        return "C"
    else:
        return "F"

def summarise(scores: list[int]) -> None:
    total  = sum(scores)
    avg    = total / len(scores)
    grades = [letter_grade(s) for s in scores]
    print(f"Average: {avg:.1f}")
    print(f"Grades: {', '.join(grades)}")

summarise([87, 92, 74, 65, 91])

A log formatter and a file processor that uses it, with a dry_run default parameter that prevents side effects unless explicitly disabled:

python
def format_log(level: str, message: str) -> str:
    return f"[{level.upper():5}] {message}"

def process_file(path: str, dry_run: bool = True) -> bool:
    print(format_log("info", f"Processing {path}"))
    if dry_run:
        print(format_log("info", "Dry run, no changes made"))
        return True
    return True

process_file("report.csv")
process_file("report.csv", dry_run=False)

A single-value normaliser and a column normaliser built on top of it, with type hints and a docstring. The column function computes the range once and reuses the scalar function for each item:

python
def normalise(value: float, min_val: float, max_val: float) -> float:
    """Scale value to the 0-1 range given known min and max."""
    if max_val == min_val:
        return 0.0
    return (value - min_val) / (max_val - min_val)

def normalise_column(values: list[float]) -> list[float]:
    """Normalise an entire column of values."""
    lo, hi = min(values), max(values)
    return [normalise(v, lo, hi) for v in values]

raw = [10.0, 25.0, 5.0, 40.0, 15.0]
print(normalise_column(raw))

Type hints here serve two purposes: they document what the function expects, and they let a type checker catch callers that pass a list of strings by mistake.