Functions
A leg-press machine in a gym is one machine that does one job: it lets you push a weighted sled with your legs. The frame never changes. The pin you put into the weight stack changes the load. Two lifters use the same machine on the same day, one stacking 200 pounds and one stacking 600. A function is that machine. The body is fixed. The arguments are the pins. Every call is a different lifter walking up.
The mathematical idea behind a function is older than computers. In the 1930s a logician at Princeton named Alonzo Church invented lambda calculus, a notation that wrote every computation as a function applied to an argument. He proved you could express any algorithm with nothing but functions — no loops, no variables, no if statements. His student Alan Turing proved the same thing with a different model and the two models turned out to be equivalent. When Guido was designing Python in 1989 he had spent years reading papers on Scheme, a language built on Church's idea, and he kept the most important piece: in Python, a function is a value. You can pass a function to another function. You can return a function from a function. You can stick a function in a list. The leg-press machine is itself a thing you can pick up and carry to a different gym.

The simplest function is def, the keyword that names a chunk of code. Open Python with python and type this.
def add(a, b):
return a + b
print(add(2, 3))
print(add(10, 7))5
17The line def add(a, b): declares a function named add that takes two parameters, a and b. The body runs only when something calls the function. The return keyword sends a value back to the caller. A function with no return returns None implicitly.
Parameters can have defaults so the caller can leave them out. Defaults make a function flexible without forcing every caller to spell out every argument.
def greet(name, greeting="hello"):
return f"{greeting}, {name}"
print(greet("Aarit"))
print(greet("Aarit", greeting="howdy"))hello, Aarit
howdy, AaritThe f"..." is an f-string — Python takes any expression in the curly braces, evaluates it, and substitutes the result into the string. The keyword form greeting="howdy" lets you pass arguments by name instead of by position. Naming arguments at the call site makes the call self-documenting and lets you skip earlier defaults you do not want to override.
When you do not know how many arguments a function will receive, two special parameter forms collect the rest. *args packs extra positional arguments into a tuple. **kwargs packs extra keyword arguments into a dict.
def show(*args, **kwargs):
print("positional:", args)
print("keyword:", kwargs)
show(1, 2, 3, name="Aarit", level=10)positional: (1, 2, 3)
keyword: {'name': 'Aarit', 'level': 10}The asterisks at the call site reverse the operation. show(*[1, 2, 3]) unpacks the list into three positional arguments. show(**{"name": "Aarit"}) unpacks the dict into keyword arguments. The rule is symmetric: define with stars, call with stars.
Now the trap that catches every Python beginner and a fair number of seniors. Default values are evaluated once, at the moment the function is defined, not each time the function is called. If the default is mutable — a list, a dict, a set — every call shares the same one. Watch this.
def add_item(item, basket=[]):
basket.append(item)
return basket
print(add_item("apple"))
print(add_item("bread"))
print(add_item("milk"))['apple']
['apple', 'bread']
['apple', 'bread', 'milk']That is not what the function looks like it should do. The reader expects each call to start with a fresh empty basket. Instead, the same basket carries over from call to call, because basket=[] was created exactly once when the function was defined, and every call that omits basket reaches into that one list. The Post-it analogy from the variables lesson explains it: basket is a Post-it that points at one specific empty-list house, and every call that does not provide its own basket sticks the same Post-it on the same house. The fix is to use None as the default and create a fresh list inside the body.
def add_item(item, basket=None):
if basket is None:
basket = []
basket.append(item)
return basket
print(add_item("apple"))
print(add_item("bread"))
print(add_item("milk"))['apple']
['bread']
['milk']Each call now starts fresh because None is the default sentinel and the real list is built inside the function body, where it is created new every call. Use this pattern any time a default would be a mutable container.

Functions can call other functions, and a function can call itself. That last move is recursion — and the call stack is the gym's storage room where every machine someone is currently using is being held until they finish their set. Each call gets its own frame in the stack with its own copy of the parameters and local variables. When the call returns, its frame disappears. Trace it on a tiny recursive function called countdown.
def countdown(n):
print(" " * (5 - n) + f"enter countdown({n})")
if n == 0:
print(" " * (5 - n) + "base case, returning")
return
countdown(n - 1)
print(" " * (5 - n) + f"exit countdown({n})")
countdown(3)The leading spaces visualize the depth of the stack at each step. The deeper the call, the further left the print starts. Run it and watch the stack grow on the way down and shrink on the way back up.
enter countdown(3)
enter countdown(2)
enter countdown(1)
enter countdown(0)
base case, returning
exit countdown(1)
exit countdown(2)
exit countdown(3)The enter lines are the stack growing. Each call pushes a frame onto the stack and immediately calls itself with n - 1, never reaching the exit line until the inner call returns. The enter countdown(0) line hits the if n == 0 branch and returns without recursing, which is the base case — the floor of the recursion. After that, every exit line you see is a frame popping off the stack as control returns to the caller. The stack grew to four frames deep and unwound to zero.
A question to answer from that output: how many enter lines did you see, and how many exit lines? Three exit lines, four enter lines. The base case countdown(0) returned without ever printing an exit, because the return happens before the print(... exit ...) line. If you wanted matching enters and exits, you would move the exit print above the return.
Python looks up names using a four-level rule called LEGB: Local, Enclosing, Global, Built-in. When your code references x, Python first checks the local scope of the function it is in, then any enclosing function scopes, then the module-level globals, then the built-in names that come with Python like print and len. The first match wins. The rule is why a variable named inside a function does not leak out, and why print always works without an import — it lives in the built-in scope at the bottom of the stack.
Functions take a value and return a value. The next bottleneck is that some functions need to hand back not one value but a stream of values, one at a time, without holding all of them in memory at once.