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 math there, hands the answer back,
and clears the desk. Local names live on that desk — not forever.
That desk is a frame. Python creates it on the call, runs the body inside it, then removes it on return. Every time, with no exceptions — 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 does not execute 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, 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 is not automatically the same as a name outside,
even if they are 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 — LEGB. 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. Nothing about len is special —
it is just a name, and Python has to walk out to the last ring to find it:
total is here
miss
rate, which is found this way
miss
TAX and outer
miss
len, print, range, str
hit
rate stops at ring E; TAX stops at ring G. That E ring is not a
footnote — it is exactly 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 quietly 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 the same notebook, not a photocopy. If they write on a page, you will see it. If they put the notebook down and pick up a new one, yours is unchanged.
When you call add_item(bag), Python does not copy the list.
It binds the local name items to the same object bag already
references. Mutation is visible to the caller. Rebinding the parameter is not.
| 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 is a syntax error — 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 only 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, one note is taken along by the inner function, which 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 does not 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] — that is the shared list. (__defaults__ itself is a tuple of every default value, so you will see (['a', 'b'],) with a trailing comma — that is 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.