Home Session 02
Foundations · Session 02

Functions, Scope & the Call Stack

Calling a function is like opening a temporary desk, doing the work, then packing it away. We will watch that desk appear — then get precise about function objects, frames, arguments, returns, and closures.

~35 minutes
5 interactive demos
Beginner friendly, deeper model
Learning goal

A call opens a temporary desk, then clears it away.

That is the everyday picture. The technical steps are: create a function object, create a call frame, bind arguments to local names, run the body, return an object, then remove the frame.

function object call frame scope arguments return closure
Recommended path
1 Watch stack frames. New function calls appear as frames above global scope.
2 Track names separately. Global and local names may have the same spelling but live in different frames.
3 Follow object identity. Arguments and returns pass references to objects, not boxes of copied values.
Interactive lab

Watch the temporary desk appear

Pick a demo and step line by line. A new call opens a desk on top of the stack. When the function returns, that desk is packed away. The top frame is the function running right now.

Tip: when a function is called, pause and ask which names are global, which names are local, and which names point to the same object.
1 Choose a demo
2 Step with or Play
3 Watch frames appear and disappear
4 Read the explanation below
Call stack — the top frame is running
Heap — function objects and values
next back Space play R reset
python
1 / 1
Memory
call stack heap
1

Initial state — only the global frame exists

Before code runs, there is one namespace: global. Press Play or use Next to step through the call.

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.

Model
A frame is Python's desk for one call
A frame is where Python tracks the local names of one call, the line it is currently on, and the way back to the caller. The exact low-level layout belongs to the runtime, but the mental model is stable: each active call has its own frame, and calls stack up — the top frame is the one running right now.
Scope

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:

python
TAX = 0.2 # G — global def outer(): rate = 0.1 # E — enclosing, as seen from inner def inner(): total = 100 # L — local to inner return len(str(total)) * rate * TAX return inner
L · inner its own locals — only total is here miss
E · outer the enclosing function — holds rate, which is found this way miss
G · module the global frame — holds TAX and outer miss
B · builtins the last ring, always there — 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.

Reading

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.

Assigning

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.

Care
Same spelling does not mean same variable
A global 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

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.

python
def order(item, qty=1, *extras, note=None, **tags): ...
The call What lands on the desk Why
order("tea") item"tea", qty1, noteNone, extras(), tags{} One positional fills item; the rest fall back to defaults, and the two sweepers come up empty.
order("tea", 2) item"tea", qty2 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

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.

Key
nonlocal means "write through to the cell"
Reading an outer name needs no declaration — that is the E in LEGB, the enclosing ring. 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.
Care
Closures capture the cell, not the value — the loop trap
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__.
Default arguments

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.

Care
The safe pattern
Use 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.
Try this in a REPL

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.

What you should be able to draw

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.

Check your understanding

Can you predict the memory?

Ask which desk the name lives on, and whether two names share one object.

1. You assign message = "hi" inside a function. There is also a global message. What happens?
Assignment makes a local by default. Same spelling, different frames, different names.
2. A function does items.append("x") on a list the caller passed. Does the caller see it?
Mutation is visible through every alias. Rebinding items = [] would not change the caller.
3. Why can a mutable default argument grow across calls?
The empty list lives on the function object. Each call that omits bag mutates that same list.