Functions
Functions package reusable behavior behind a name: defined once with def, they take input through parameters, run their own local scope, and hand back a result with return — or None if they don't.
A function is a named, reusable block of code that performs a task, optionally accepting input values and optionally producing an output value. Functions are the primary tool for organizing behavior in Python: they let you write logic once and invoke it from anywhere, name a concept so intent is clear at the call site, and isolate local work from the rest of the program through their own scope.
Defining a Function with def
A function definition starts with the def keyword, the function's name, a parenthesized (possibly empty) parameter list, and a colon, followed by an indented block — the function body. Defining a function does not run its body; the body only executes when the function is later called.
The -> str after the parameter list is an optional return type annotation, and name: str is a parameter type annotation. Annotations document intent and let external tools (a third-party/ecosystem tool like mypy) check types statically, but Python itself does not enforce them at runtime.
def greet(name: str) -> str:
message = f'Hello, {name}!'
return message
# nothing has printed yet -- the function is only defined, not called
result = greet('Ada') # NOW the body runs
print(result) # Hello, Ada!The Function Body and Its Own Local Scope
Every function call creates a fresh local scope: names assigned inside the function body (including parameters) exist only for that call and are not visible outside the function. Each call gets its own independent set of local names, even for the same function called multiple times, and recursive calls each get their own scope too.
A function body can read names from an enclosing (outer) scope without special declaration, but assigning to a name inside the function makes Python treat it as local for that entire function body, unless explicitly declared global or nonlocal (covered under scope/closures topics).
def compute():
local_value = 42 # exists only inside this function call
return local_value
compute()
print(local_value) # NameError: 'local_value' is not defined out herecounter = 0
def show_counter():
print(counter) # fine: reading an outer-scope name
show_counter() # 0Invocation: Calling a Function
A function is invoked (called) by writing its name followed by parentheses containing any arguments, e.g. greet('Ada'). Calling transfers control into the function body; when the function finishes (via return or by reaching the end of its body), control resumes at the call site with whatever value the call produced.
def square(n):
return n * n
print(square(5)) # 25 -- call, get value back
values = [square(x) for x in range(4)]
print(values) # [0, 1, 4, 9] -- called repeatedly in a comprehensionParameters and Arguments
Parameters are the names listed in a function's definition; arguments are the actual values supplied at a call site. Python supports positional arguments (matched by position) and keyword arguments (matched by name), and parameters may have default values, making them optional at the call site.
Function arguments are passed using call-by-object-sharing: the parameter receives a reference to the same object the caller passed. Rebinding the parameter inside the function does not affect the caller's variable, but mutating a mutable argument in place is visible to the caller — see the mutation caveat below.
def introduce(name, role='Engineer'):
return f'{name} works as a {role}'
print(introduce('Sam')) # Sam works as a Engineer
print(introduce('Sam', 'Manager')) # positional
print(introduce(name='Sam', role='Lead')) # keywordreturn and What It Does
return immediately exits the function and hands a value back to the caller. return with no value (or falling off the end of the function body with no return at all) hands back None. A function can have multiple return statements on different code paths; the first one reached ends the call.
def classify(n):
if n > 0:
return 'positive'
if n < 0:
return 'negative'
return 'zero' # reached only if neither earlier return fired
print(classify(-3)) # 'negative'Functions That Return None
A function that performs an action (printing, mutating, saving) but produces no meaningful result implicitly returns None — this is completely normal and expected, not an error. Watch out for accidentally using such a function's return value as though it were meaningful data.
def log_message(msg):
print(f'[LOG] {msg}')
# no explicit return -- implicitly returns None
result = log_message('starting up')
print(result) # None# Common bug: mistaking a None-returning method for one that returns a new object
numbers = [3, 1, 2]
sorted_numbers = numbers.sort() # list.sort() sorts IN PLACE and returns None
print(sorted_numbers) # None -- not the sorted list!
print(numbers) # [1, 2, 3] -- the actual result is here
# Correct: use sorted() to get a new sorted list back as a return value
sorted_numbers = sorted(numbers) # returns a new listMutation of Mutable Arguments: Visible to the Caller
Because arguments are passed by object reference, a function that mutates a mutable argument in place (appends to a list, sets a dict key) changes the exact object the caller holds — even though nothing was explicitly returned. This is intentional and often useful, but must be done deliberately, since it's a form of side effect on the caller's data.
def add_item(cart: list, item: str) -> None:
cart.append(item) # mutates the caller's actual list
my_cart = ['apples']
add_item(my_cart, 'bread')
print(my_cart) # ['apples', 'bread'] -- visible to the caller, no return neededReusable Behavior: Why Functions Matter
Wrapping logic in a function serves several purposes at once: it avoids duplicating the same code in multiple places (a single fix or improvement applies everywhere the function is called), it names a concept so the call site reads as intent rather than mechanism, and it creates a boundary — inputs come in as parameters, a result goes out via return, and everything in between is encapsulated in its own local scope.
# Without a function -- logic duplicated and unnamed
celsius1 = 20
fahrenheit1 = celsius1 * 9 / 5 + 32
celsius2 = 30
fahrenheit2 = celsius2 * 9 / 5 + 32
# With a function -- logic named once, reused, easy to fix in one place
def celsius_to_fahrenheit(celsius: float) -> float:
return celsius * 9 / 5 + 32
fahrenheit1 = celsius_to_fahrenheit(20)
fahrenheit2 = celsius_to_fahrenheit(30)Functions as First-Class Objects (Brief Preview)
In Python, functions are objects like any other value — they can be assigned to variables, stored in data structures, and passed as arguments to other functions. This is a deep topic covered in later material (higher-order functions, decorators); the essential fact to know here is that def creates an object bound to the function's name, just as x = 5 binds a name to an integer object.
def shout(text):
return text.upper() + '!'
say_it = shout # 'say_it' now refers to the same function object
print(say_it('hello')) # HELLO!Documenting a Function: Docstrings
A string literal as the very first statement in a function body becomes its docstring — accessible via the function's __doc__ attribute or help(). Convention favors a short imperative summary line, expanded with more detail for public/complex functions.
def area_of_circle(radius: float) -> float:
"""Return the area of a circle with the given radius."""
import math
return math.pi * radius ** 2
print(area_of_circle.__doc__) # Return the area of a circle with the given radius.Key Takeaways
def defines a function without running its body; the body only executes when the function is later called.
Every call gets its own fresh local scope — names assigned inside a function (including parameters) don't leak out or persist between calls.
Parameters can be supplied positionally or by keyword, and may have default values that make them optional at the call site.
return immediately exits with a value; a function with no return (or a bare return) implicitly returns None — this is normal, not an error.
Arguments are passed by object reference: rebinding a parameter never affects the caller, but mutating a mutable argument in place does.
Functions exist to eliminate duplication, name intent clearly, and encapsulate logic behind an input/output boundary.
Functions are themselves objects — a name bound with def can be assigned, passed around, and stored just like any other value.
def add_to_list(item, target=[]): target.append(item) return target def add_to_list(item, target=None): if target is None: target = [] target.append(item) return target
A mutable default argument is created ONCE, when the function is defined, and reused across every call that doesn't supply its own value -- leading to a list that silently accumulates items across unrelated calls. Use None as the default and create a fresh mutable object inside the body instead.
result = numbers.sort() print(result) # expecting the sorted list numbers.sort() print(numbers) # the list is sorted in place # or: result = sorted(numbers) # returns a new sorted list
list.sort() sorts in place and returns None -- it's a common mistake to assume it returns the sorted list like sorted() does. Check whether a method mutates in place (returns None) or returns a new value before relying on its return.
def get_total(prices): total = 0 for p in prices: total += p # forgot to return total def get_total(prices): total = 0 for p in prices: total += p return total
Falling off the end of a function body without an explicit return returns None. Forgetting the final return is a common bug that silently produces None instead of the intended computed value.
def rename(user): user = user.upper() # only rebinds the local parameter, not the caller's variable name = 'ann' rename(name) print(name) # still 'ann' -- unchanged def rename(user: str) -> str: return user.upper() name = 'ann' name = rename(name) print(name) # 'ANN'
Rebinding a parameter inside a function only changes what that local name points to; it never affects the caller's variable, because strings are immutable and the caller's binding is untouched. Return the new value and reassign at the call site instead.
What happens when you write a def statement -- does the function body run immediately?
No. def only creates the function object and binds it to a name; the body does not execute until the function is later called.
What does a function return if it has no explicit return statement, or a bare return with no value?
It returns None in both cases -- this is completely normal Python behavior for functions whose purpose is a side effect rather than producing a value.
Explain why mutating a list argument inside a function is visible to the caller, but reassigning that same parameter name is not.
Arguments are passed by object reference (call-by-object-sharing): the parameter and the caller's variable point to the same object. Mutating that object in place (e.g. .append()) changes the shared object, visible everywhere it's referenced. Reassigning the parameter just points the LOCAL name at a different object, leaving the caller's variable pointing at the original, unaffected object.
Why is using a mutable default argument (like def f(x, items=[]):) considered a common bug?
Default argument values are evaluated once, at function definition time, not on every call -- so all calls that don't supply their own items share and mutate the exact same list object across calls, causing state to leak between unrelated invocations.
What is the difference between a parameter and an argument?
A parameter is the name declared in the function's definition (e.g. def greet(name): -- name is the parameter). An argument is the actual value supplied at a call site (e.g. greet('Ada') -- 'Ada' is the argument).
Why does each call to the same function get its own independent local scope?
Python creates a new local namespace (frame) for every function call, so names assigned inside the body -- including parameters -- exist only for that particular call and don't persist or interfere with other calls, even recursive calls to the same function.
What is a docstring, and how is it different from a regular comment inside a function?
A docstring is a string literal placed as the very first statement in a function's body; unlike a # comment, it becomes a real, inspectable attribute of the function object (__doc__), retrievable at runtime via help() or function.__doc__, which comments cannot provide.
What does each call print?
15, 25, 3. First call uses the default y=10 (5+10=15). Second call overrides it positionally (5+20=25). Third call uses keyword arguments in any order (2+1=3).
Write a function average(numbers) that returns the average of a list of numbers, returning None if the list is empty (to avoid a division-by-zero error).
def average(numbers: list[float]) -> float | None: if not numbers: return None return sum(numbers) / len(numbers) print(average([2, 4, 6])) # 4.0 print(average([])) # None
The guard clause if not numbers: catches the empty-list edge case up front, returning None explicitly and avoiding a ZeroDivisionError from len(numbers) being zero in the denominator.
What does this print, and why does part of the function's work show up but not all of it?
[1, 2, 3, 4]. The first lst.append(4) mutates the actual list the caller passed in, which IS visible afterward. The subsequent lst = [100, 200] rebinds the LOCAL name lst to a brand-new list -- from that point on, further operations on lst (like the second .append(300)) only affect that new local list, never touching the caller's original list again.
Write a function safe_get(dictionary, key, default=None) that returns the value for key if present, or default otherwise, without using dict.get() directly (use an if/else and manual lookup logic to demonstrate parameters and return).
def safe_get(dictionary: dict, key, default=None): if key in dictionary: return dictionary[key] return default config = {'timeout': 30} print(safe_get(config, 'timeout')) # 30 print(safe_get(config, 'retries')) # None print(safe_get(config, 'retries', 3)) # 3
The function takes a default parameter value (default=None) making it optional at the call site, checks membership explicitly with in, and returns early via one of two return statements depending on whether the key was found -- demonstrating parameters, default arguments, and multiple return paths.
def mystery(x, y=10):
return x + y
print(mystery(5))
print(mystery(5, 20))
print(mystery(y=1, x=2))def modify(lst):
lst.append(4)
lst = [100, 200]
lst.append(300)
numbers = [1, 2, 3]
modify(numbers)
print(numbers)