What happens when Python calls a function?
In plain words
You write add(2, 3) and expect an answer. Behind the scenes Python pulls up a short-lived desk, does the maths there, hands the answer back, and clears the desk. Local names live on that desk, and only for as long as it exists.
That desk is a frame. Python creates it on the call, runs the body inside it, then removes it on return. Every time, without exception. The objects that were on it can outlive it, but the frame itself never does.
Function object
Running def creates a function object and binds a name to it. The body is stored for later. It doesn't run at definition time.
Call frame
Calling the function creates a new frame. Parameters become local names in that frame, each bound to the argument objects.
Return path
return hands an object back to the caller, and then the frame is removed, always. If an inner function captured a name, that one name lives on in a separate little box the inner function carries. The desk is still cleared.
Local names are separate from global names
In plain words
A name inside a function isn't automatically the same as a name outside, even if they're spelled the same. Think of two sticky notes that both say message, stuck on two different desks.
A scope is where Python looks up a name. Reading works outward
through four rings, and the first one that has the name wins:
Local, Enclosing functions, Global,
Built-ins, or LEGB for short. Assignment is the opposite: it always
writes to the innermost ring unless you declare global or nonlocal.
Here are all four rings in one program. There's nothing special about len. It's just a name, and Python has to walk out to the last ring to find it:
total lives
miss
rate (that one is found this way)
miss
TAX and outer
miss
len, print, range, str
hit
rate stops at ring E and TAX stops at ring G. That E ring isn't a footnote. It's the machinery behind closures, which demo 4 takes apart. And because the last ring is a real namespace you can shadow, writing list = [1, 2] at the top of a file hides the built-in list for the rest of that module.
Name lookup searches outward
If a name is not found in the local frame, Python looks in enclosing scopes, then globals, then built-ins. That is why a function can read a global constant.
Assignment creates a local name by default
If you assign to message inside a function, Python treats it as local unless
you declare global message or nonlocal message.
message and a local message can both exist at the same time.
They are different names in different frames. The animation above shows both names pointing
to different string objects.
Arguments bind parameter names to existing objects
In plain words Passing a list into a function is like handing someone your notebook rather than a photocopy. If they write on a page, you'll see it. If they put the notebook down and pick up a new one, yours is unchanged.
When you call add_item(bag), Python doesn't copy the list. It binds the local name items to the same object bag already refers to. Mutation is visible to the caller. Rebinding the parameter isn't.
| Operation inside function | What changes? | Caller sees it? |
|---|---|---|
items.append("x") |
The list object is mutated in-place. | Yes, because caller and parameter point to the same list. |
items = ["x"] |
The local name is rebound to a new list. | No, the caller's name still points to the original list. |
return items |
The referenced object is handed back to the caller. | Only if the caller stores or uses the returned object. |
How Python decides which name gets which object
The binding above is the same whatever the call looks like, but which parameter name each object lands on depends on how you wrote the call. Python fills the desk in a fixed order: positional arguments first, left to right, then anything passed by keyword, then defaults for whatever is still unfilled. A *name parameter sweeps up the
leftover positionals into a tuple, and **name sweeps up the leftover keywords
into a dict.
| The call | What lands on the desk | Why |
|---|---|---|
order("tea") |
item → "tea", qty → 1, note → None, extras → (), tags → {} |
One positional fills item; the rest fall back to defaults, and the two sweepers come up empty. |
order("tea", 2) |
item → "tea", qty → 2 |
Positionals are matched strictly left to right, so the second one goes to the second parameter. |
order(qty=2, item="tea") |
Identical to the row above | Keywords are matched by name, so their order does not matter. Written the other way round, order(qty=2, "tea"), it's a syntax error, because positionals may never follow keywords. |
order("tea", 2, "hot", "large", note="rush", size="L") |
extras → ("hot", "large"), note → "rush", tags → {"size": "L"} |
Leftover positionals collect into the *extras tuple; note is named so it binds directly, and the unrecognised size collects into the **tags dict. |
order(*pair, **opts) |
Same as spelling the items out by hand | At the call site the stars mean "unpack": * spreads a sequence into positionals, ** spreads a dict into keywords. Same two stars, opposite direction from the def. |
Nothing here changes the model. However an object arrives, the result is the same:
a local name on the call's desk, pointing at an object the caller already had.
*extras and **tags are the one exception worth noting. Those two objects, a fresh tuple and a fresh dict, really are built for that one call.
Closures let a function remember an outer name
In plain words Usually, when a function finishes, the desk is cleared and its notes go with it. A closure is the exception. Before the outer desk is cleared, the inner function takes one note along and keeps it in its pocket.
That pocket is real and it has a name. When Python compiles an outer function
and sees that an inner one uses one of its names, it doesn't store that name on the desk at all. It puts it in a cell, a one-slot box that the inner function object holds a reference to. You can look at them:
inc.__closure__ is a tuple of cells, one per captured name, and
inc.__closure__[0].cell_contents is the value in the first one.
The frame is still removed on return. Only the cells survive.
nonlocal means "write through to the cell"nonlocal count is only needed to assign:
without it, count = count + 1 would make a brand-new local name inside
inc (and fail, because it would be read before assignment). With it,
both the read and the write go straight to the shared cell. Step demo 4 and watch
inc()'s desk: it stays empty, because inc has no locals at all.
fs = [lambda: i for i in range(3)] then [f() for f in fs]
gives [2, 2, 2], not [0, 1, 2]. All three functions share
one cell for i, and by the time you call them the loop has
finished and that cell holds 2. This is the closure bug people actually hit: callbacks registered in a loop all firing with the last value. The fix is to
copy the value at definition time with a default argument,
lambda i=i: i, or with functools.partial(lambda i: i, i).
Both give [0, 1, 2].
What you should understand by the end
| Concept | Plain-English meaning |
|---|---|
| Function object | The object created by def; a name can point to it like any other object. |
| Frame | The temporary desk (one namespace) belonging to one active call. |
| Local scope | The names that belong to a particular call frame. |
| Argument passing | Parameter names are bound to the objects supplied by the caller. |
| Closure | An inner function object plus the cells holding the outer names it captured. Reachable at fn.__closure__. |
Mutable defaults are created once
In plain words
The empty list in def add_item(item, bag=[]) is packed when you write the function, not each time you call it. Every call that skips
bag reuses that one list, so it keeps growing.
Default values are evaluated at definition time and stored on the function object. Immutable defaults (numbers, strings) are safe to share. Mutable ones are the classic surprise.
None as the default and create a new list inside the function:
def add_item(item, bag=None): then if bag is None: bag = [].
Immutable defaults such as numbers and strings are safe to share.
Define def add_item(item, bag=[]): bag.append(item); return bag,
then call it twice with "a" and "b". Print both results.
Then inspect add_item.__defaults__[0], which is the shared list. (__defaults__ itself is a tuple of every default value, so you'll see (['a', 'b'],) with a trailing comma. That's a one-item tuple, not an error.) Then do the same with the bag=None version and check one is two.
A function object on the heap, a call frame that appears and disappears, parameters as extra names for the caller's objects, and, for closures, the cells that outlive the outer frame and hold the names it captured.
Five ways this goes wrong
In plain words Each of these comes from forgetting one of the three desk rules: a call gets its own desk, assignment writes on the desk you're at, and the desk is cleared on the way out.
Assigning to a global inside a function
count = 0 at the top of the file, then count += 1 inside a
function, fails with UnboundLocalError: cannot access local variable 'count'
where it is not associated with a value (Python 3.10 words it
referenced before assignment). Because the function assigns
to count, Python decided at compile time that count is local to that desk, and the read on the right-hand side happens before anything was written there. Declare global count if you really mean the module name, or, more
often, return the new value instead of reaching outside.
Forgetting return
A function that only prints hands back None. The error shows up one line later, as TypeError: 'NoneType' object is not subscriptable or unsupported operand type(s) for +: 'NoneType' and 'int', when the caller tries to use the result. If a variable is unexpectedly None, look at
the last line of the function that produced it.
A mutable default argument
def add_item(item, bag=[]) creates one list when the def runs
and hands it to every call that omits bag. Demo 5 shows it growing across
calls. The fix is the pattern above: default to None and build the list
inside the call.
Shadowing a built-in
list = [1, 2] at the top of a file, and every later list(...)
in that module raises TypeError: 'list' object is not callable. The
built-ins ring is the last one LEGB checks, so any module-level name with the same
spelling wins first. Names like list, dict, id,
type and input are the usual casualties.
Calling the function when you meant to pass it
Thread(target=work()) runs work right now, on the current
desk, and passes its return value, usually None, as the target.
The thread then starts and does nothing. The same slip hides in
button.on_click(save()) and sorted(items, key=priority()).
A function object is a value like any other. The parentheses are what run it.
UnboundLocalError. What object came back from the call? That finds the missing return, the shared default list and the function that was called by accident.