Skip to content

Functions

docs.scrimba.com

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 main unit of code reuse and abstraction. They wrap up a behaviour, give it a name, define a clear interface (its parameters and return value), and make it callable from anywhere. Well-named functions also read as documentation: validate_email() tells you what a block of code does without reading the body.

In Python a function is a first-class object: a value you can pass around like any other, so it can be assigned to a variable, stored in a list or dict, handed to another function as an argument, and returned from one. def creates that object and binds it to a name in the current scope (the region of code where that name is visible). Treating functions as values is what makes patterns like sorted(key=...) and small plugin tables possible, and it is worth getting comfortable with early.

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 builds a function object and binds it to the name in the current scope. The body does not run when Python reads the def, it runs only when you call the function. That split matters in practice: a typo inside a function body (a name that does not exist, say) stays silent until the function is actually called, so a function you never call can hide a NameError that only surfaces in production the day someone calls it.

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

say_hello()   # call the function
JunoDefining a function Write def name(): and indent the body underneath. Nothing happens until you call it with name(), the def only sets it up. And if you forget a return, the function quietly hands back None, which caught me out more than once early on.
JunoDefining a functiondef creates the function and binds it to a name, but the body only runs when you call it. No return means the function returns None. Keep that in mind when a result comes back empty for no clear reason.
JunoDefining a function The body doesn't run at def time, only when you call the function, so a broken name inside can sit unnoticed until something actually calls it. A function with no return returns None, which is its own quiet bug when a caller expects a value.

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.

At call time, positional arguments bind left to right and keyword arguments bind by name. Passing the same parameter both ways, or one Python can't place, raises TypeError at the call, not inside the body. Python also lets you constrain a signature: a bare * makes everything after it keyword-only (callers must name it), and a / makes everything before it positional-only. Reach for keyword-only on boolean flags and wide signatures, where a positional True at the wrong spot is the kind of mistake that compiles fine and ships a bug.

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

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

Parameter is the name in the function definition. Argument is the actual value you pass when you call it. In practice people use the words interchangeably; the distinction mainly matters when reading docs.

JunoParameters and arguments Parameters are the names you list in the parentheses, arguments are the actual values you hand over when you call. Python lines them up left to right, so the order you pass them in is the order they land in.
JunoParameters and arguments Parameters are the names in the definition, arguments are the values at the call. Positional ones bind left to right, so position is the contract. The two words get used interchangeably, the gap only matters when you read docs.
JunoParameters and arguments Positional arguments bind left to right, keyword ones by name, and a clash raises TypeError right at the call. A bare * forces callers to name what follows it, which is the cheap fix for a function whose True/False flags would otherwise be a guessing game at the call site.

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.

The detail that bites: a default value is built once, when def runs, and shared by every call that uses it. For an immutable default like 0 or "Hello" (one you can't change in place) that is fine. For a mutable default like a list or dict (one you can change in place) it is a trap, because every call that falls back to the default reuses the same object:

python
def add_item(item, items=[]):   # the list is created once, shared forever
    items.append(item)
    return items

add_item("a")   # ['a']
add_item("b")   # ['a', 'b']  <- not a fresh list

The fix is the standard one: default to None, then build a fresh container inside the body.

python
def add_item(item, items=None):
    if items is None:
        items = []
    items.append(item)
    return items
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.

JunoDefault values Give a parameter a default and it becomes optional, the caller can skip it. The defaulted ones have to come after the required ones, that order is a rule Python enforces. Saves you writing the same value at every call.
JunoDefault values Defaults make a parameter optional and must sit after the required ones. The catch: a default is evaluated once at definition, so never use a mutable one like []. Default to None and build the list inside the body instead.
JunoDefault values A default is built once at def time and shared across calls, so items=[] quietly accumulates between calls. Default to None and make a fresh one in the body. This one shows up in real code review more than almost any other Python slip.

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, so they read well and free you from remembering positions. The rule at the call site: positional arguments come first, keyword ones after, and naming the same parameter twice raises TypeError. The design move worth making is the reverse: put a bare * in your own signature so callers must name a parameter (def connect(host, *, timeout=30)). It costs nothing and turns an opaque connect("db", 30) into a connect("db", timeout=30) that survives someone adding a parameter in the middle later.

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
JunoKeyword arguments Name the arguments at the call (score=87) and the call explains itself, plus you can pass them in any order. The one rule: any positional arguments come before the named ones. Great for functions with a pile of parameters.
JunoKeyword arguments Naming arguments makes a call self-documenting and order-independent, with positional ones required first. For wide signatures or boolean flags, prefer keyword arguments so nobody has to count positions to read the call.
JunoKeyword arguments Keyword arguments bind by name and must follow any positional ones. The real lever is your own signature: a bare * forces callers to name what comes after, so the call still reads right after someone slots a new parameter in the middle.

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 hands a value to the caller and ends the function on the spot, and a function that runs off the end with no return returns None. Multiple returns are good style, not a smell: an early return for the failure or edge case up top (the "guard clause" pattern) reads far better than nesting the whole body inside one big if. One subtlety to know before it surprises you: a return inside a try block still runs the matching finally block first, so cleanup in finally happens even when you return early.

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.

JunoReturn valuesreturn hands a value back to whoever called the function, and the function stops right there, anything after it is skipped. Leave out return and you get None back. print shows a value, return gives it back to use, took me a while to feel that difference.
JunoReturn valuesreturn passes a value back and exits immediately, no return means None. You can have several, and an early return for the edge case up top usually reads cleaner than wrapping everything in one big if.
JunoReturn valuesreturn exits on the spot and falling off the end gives None. Lean on early returns as guard clauses instead of deep nesting. And remember a return inside try still runs the finally block, so cleanup there fires even on an early exit.

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, and the caller pulls them apart with low, high = f(). It reads cleanly for two or three results. Past that, position becomes a liability: nobody calling the function remembers whether the third item was the count or the average, and a swapped pair is a silent bug. At that point return something named instead, a NamedTuple or a @dataclass, so callers reach for result.average rather than result[2]. Annotate the return as tuple[int, str] either way so a type checker can catch a caller unpacking the wrong number of values.

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.

JunoReturning multiple values Separate values with a comma and the function returns them together as a tuple. The caller catches them in one line, low, high = min_max(...), one name per value. Handy whenever a function naturally produces more than one answer.
JunoReturning multiple values A comma packs the values into a tuple, the caller unpacks with matching names. It's plain tuple packing and unpacking, not a special feature. Clean for two or three results, less so past that.
JunoReturning multiple values Comma-return packs a tuple, fine for two or three values. Beyond that, position turns into a trap nobody can read, so return a NamedTuple or @dataclass and let callers use result.average instead of counting indices.

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.

A name created inside a function is local: it lives only there and vanishes when the function returns. A name defined at the top level of the file is global. Reading a global from inside a function works without ceremony, but assigning to one needs global name, otherwise Python treats the assignment as creating a new local that shadows the global. In practice you rarely want global at all: passing values in as arguments and handing results back with return keeps a function's effects visible at the call site.

Python resolves a name with the LEGB rule, checking four places in order: Local (this function), Enclosing (an outer function wrapping this one), Global (the module), then Built-in (names like len). Reading walks that chain; assigning always creates a local unless you say otherwise. That asymmetry is the bug source: count += 1 against a global without a global count line raises UnboundLocalError, because the assignment marks count local for the whole function and the read happens before any local value exists. global name opts an assignment back to module level; nonlocal name targets the nearest enclosing function instead, which is how a closure updates a variable from the function that made it. Treat both as a smell: a function that reaches out to mutate outer state is harder to test than one that takes inputs and returns outputs.

JunoScope A variable made inside a function lives only there, the outside can't see it. You can read an outer variable from inside, but changing one needs a global line first. Reach for that almost never: pass values in, return results out, and the function stays simple to follow.
JunoScope Names made inside a function are local and gone when it returns. Reading a global is free, writing one needs global name or Python makes a local that shadows it. Prefer arguments in and return out so a function's effects stay visible at the call.
JunoScope LEGB for lookups, but assignment always makes a local unless you declare global or nonlocal, which is why count += 1 on a global throws UnboundLocalError. Both keywords are a smell: a function that mutates outer state is harder to test than one that takes inputs and returns outputs.
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. Scope builds directly on how assignment binds a name to a value, covered in the Variables and types chapter.

*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 gathers the leftover positional arguments into a tuple, **kwargs gathers the leftover keyword ones into a dict. The mirror image happens at the call site: func(*some_list) spreads a list into positional arguments and func(**some_dict) spreads a dict into keyword ones. That symmetry is what makes a pass-through wrapper work, def timed(*args, **kwargs): return inner(*args, **kwargs) forwards whatever it got without naming a single parameter. Useful, but it costs you the readable signature: a wrapper that swallows everything into *args, **kwargs shows callers and type checkers nothing about what it accepts, so reach for it when you actually need to forward arbitrary arguments, not as the default shape for an ordinary function.

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)
Juno*args and **kwargs*args scoops up any number of extra positional arguments as a tuple, **kwargs scoops up extra named ones as a dict. The words args and kwargs are only convention, the * and ** do the real work. Handy when you can't say upfront how many things come in.
Juno*args and **kwargs*args collects extra positional arguments into a tuple, **kwargs extra keyword ones into a dict. Order in the signature: regular params, then *args, then keyword-only, then **kwargs. Most useful for wrappers that forward arguments straight on to another function.
Juno*args and **kwargs* and ** collect in a signature and spread at a call site, which is what lets a wrapper forward everything with inner(*args, **kwargs). The cost is a signature that tells callers nothing, so keep it for real pass-throughs, not ordinary functions.

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.

A docstring is the first thing in the function body, a string in triple quotes. Tools read it: help(func) prints it, and your editor shows it when you hover a call, so it pays off exactly when you have forgotten what the function does. The convention is one summary line, then a blank line and more detail if needed. Treat it as required on anything called from more than one place, optional on a throwaway helper whose name already says everything.

A docstring is the first statement in a function, class, or module body, and it should say what the function does and what it returns, not restate the parameter list a reader can already see. It earns its keep on anything whose contract isn't visible from the signature: the edge cases, what it raises, what an empty input does. Skip the formal arg-by-arg block when type hints in the signature already carry the types, that detail belongs in the hints, not duplicated in prose that drifts out of date. The aim is the line you would want to read at 2am while debugging a call you wrote six months ago.

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 whose purpose is not clear from its name and signature.

JunoDocstrings A docstring is a string in triple quotes at the very top of a function, describing what it does. Your editor shows it when you hover the function later, which is when you'll be glad it's there. Write one whenever the name and inputs don't already make the purpose clear.
JunoDocstrings First line of the body, triple-quoted: help() and your editor surface it on hover. One summary line covers most functions, add detail below only when the behaviour needs it. Required on anything called from several places, skippable on a one-liner whose name says it all.
JunoDocstrings Say what the function does, what it returns, and the edge cases, not a restated parameter list the reader can already see. Let type hints carry the types so you're not maintaining the same facts twice. Aim for the line you'd want at 2am debugging a call you wrote half a year ago.

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 change nothing at runtime, Python runs identically with them or without. Their whole value is the layer outside the program: a type checker (mypy or pyright) reading the code before it runs, and your editor flagging a bad call as you type it. So a hint that lies is worse than no hint, because the checker trusts it. Use list[int], dict[str, int] and str | None directly (no typing import needed on modern Python), and annotate a function that returns nothing as -> None, which also signals that calling it in an expression (x = log(...)) is a mistake. The payoff lands on functions called from many places: the checker catches a caller passing the wrong type instead of that surfacing as a confusing failure three frames deep at runtime.

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.

JunoType hints Type hints note what a function takes and gives back, like name: str and -> str for the return. Python won't enforce them when it runs, but your editor reads them and warns you before you hit run. Think of them as notes the tools can check.
JunoType hints Hints are documentation a type checker can verify, with no runtime effect. -> None is the right annotation when a function returns nothing, and list[int] / dict[str, int] work without a typing import on modern Python. Worth it on anything called from several places.
JunoType hints Hints do nothing at runtime, their value is the checker and editor reading them, so a hint that lies is worse than none. Use str | None and list[int] directly, -> None for no return. The win is catching a wrong-typed caller before it fails three frames deep.

Functions as values

Functions in Python are values, 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().

A function is an object you pass by reference, never a copy, so handing one to another function is cheap. The pattern that grows out of this is the closure: a function defined inside another that still reaches the outer function's variables after the outer one has returned. It is how you build a configured function on the fly, a make_multiplier(3) that returns a function multiplying by 3, with the 3 captured and held. Two cautions in real code: a closure captures the variable, not its value at capture time, which catches people building closures in a loop (they all see the loop's final value); and stacking many returned functions makes call paths hard to trace, so use closures where they read clearly and reach for a class when the state grows.

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 Lambdas and comprehensions chapter.

JunoFunctions as values A function is a value, like a string or a number, so you can store it in a variable and hand it to another function. That's exactly what lets you pass a function as key= to sorted(). Felt strange to me at first, then it clicked and turned up everywhere.
JunoFunctions as values Functions are first-class objects: assign them, store them in a list, pass them, return them. That's the engine behind sorted(key=...), map() and filter(), and behind every callback you'll wire up later.
JunoFunctions as values Passing a function is a reference, not a copy, and a closure lets an inner function keep reaching the outer one's variables after it returns. It captures the variable, not the value, so building closures in a loop bites every time. When the captured state grows, a class reads better.

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 = []
    for s in scores:
        grades.append(letter_grade(s))
    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.