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'll watch that desk appear, then get precise about function objects, frames, arguments, returns and closures.

~35 minutes
5 interactive demos
Builds on Session 01
Learning goal

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

That's the everyday picture. The technical steps are: create a function object, create a call frame, bind the 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 through it line by line. A new call opens a desk on top of the stack, and 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 the one 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 any 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 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.

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, calls stack up, and 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 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:

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, where only total lives miss
E · outer the enclosing function, which holds rate (that one is found this way) miss
G · module the global frame, which holds TAX and outer miss
B · builtins the last ring, always there: 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.

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 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.

python
def order(item, qty=1, *extras, note=None, **tags): ...
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

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.

Key
nonlocal means "write through to the cell"
Reading an outer name needs no declaration. That's 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], 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.

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.

Common mistakes

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.

Check
Two questions catch nearly all of it
Which desk is this name on? That finds the shadowed global and the 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.
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.
4. count = 0 at module level, then def bump(): count += 1. What does bump() do?
Assignment anywhere in the body makes the name local to that desk for the whole call. The count + 1 on the right then reads a local that has no value yet. global count changes the rule, and returning a value avoids the question altogether.
5. def greet(): print("hi"), then x = greet(). What is x?
print writes to the screen and returns None, and so does a function that falls off its last line. The value you see printed and the value handed back to the caller are two different things.
6. fs = [lambda: i for i in range(3)], then [f() for f in fs]. What comes out?
A closure captures the cell, not the value. There is one i cell for the whole comprehension, and by the time any lambda is called it holds 2. lambda i=i: i copies the value at definition time and gives [0, 1, 2].