◈ python mapVol 1 · Ch 09/14
Volume 1 Python, from the metal up · chapter 09

09Machines with names

In Chapter 8 we learned to pick the right container on sight. In this one we build the machines that work on them. I want to go slow, because there's a question most tutorials walk straight past. Here's the plan. We bundle a run of steps into a named unit and call it again and again. We hand it 'Blinding Lights' at 200s, then 'Titanium' at 245s — the same machine both times. Then we steer exactly how those arguments find their parameters, and tell whether a call hands back a value or a quiet None. The whole way through we keep asking the one thing that matters: what does def really build in memory, and what wakes it the moment a pair of parentheses finally runs? By the end we've watched frames push and pop on the call stack. We've climbed the LEGB ladder Python uses to resolve a name. And we've met the strange part: a function is itself a value, so you can pass the machine, not just its output.

iolinked · chapter 09 — the checkpoints6 steps
$ sections covered in Machines with names
01A machine with a name
02How arguments find their parameters
03What comes back: return, or None
04The call stack: frames that push and pop
05LEGB: where Python looks up a name
06Functions are values: pass the machine, not its output

01A machine with a name

Let's start with the pain, because it's the whole reason functions exist. Printing one song takes three lines. Printing forty songs, three lines each, would be a hundred and twenty. That's the same three lines copied out thirty-nine needless times, every copy a fresh chance to fumble one. Now watch the better move. Write the three lines once, hang a name on the bundle, and from then on just say the name. That's the whole of defbundle the steps, name the bundle. You're building a small machine and writing its name on the side.

describe.pypython
def describe(title, artist, seconds):
    line = title + " by " + artist
    print(line, "-", seconds, "s")

describe("Blinding Lights", "The Weeknd", 200)   # Blinding Lights by The Weeknd - 200 s

Look hard at the two moments in that snippet, because they are genuinely different things. The names in the def line — title, artist, seconds — are the parameters. They're labelled empty slots built into the machine's design, waiting. The values you drop in at the call — "Blinding Lights", "The Weeknd", 200 — are the arguments: what actually arrives when you press GO. Here's a mnemonic worth keeping. The parameter is part of the machine. The argument is what shows up at the door.

SYNTAX · def — build the machine, write its name on the sidethree shapes, in Ajai's Type 1/2/3 order — no slots, slots, slots and an answer
def name(): — Type 1: no slots. Does a job, hands back None body def name(parameter_1, parameter_2): — Type 2: named slots, still hands back None body def name(parameter): — Type 3: slots AND an answer """one line saying what it does""" — the docstring, optional but free body return value — hand one object back, and stop name() — the call: the parentheses press GO answer = name(argument) — catch what Type 3 handed back
Type 1No parentheses content, no return. It runs for its effect — it prints, it draws, it saves. The call still evaluates to None.
Type 2Same machine with slots cut into it. The names in the def line are parameters; the values at the call are arguments.
Type 3Adds return, and that changes everything. The call becomes a value you can name, add, print, or pass straight into another call.
the partsFour, in a fixed order: the def line, the optional docstring, the indented body, and the return. Only the def line and one body line are compulsory.
the callgreet is the machine sitting cold. greet() is the machine running. The parentheses are a separate operator, not part of the name.
you type
def greet():
    print("Hey, how are you")

def describe(title, artist):
    print(title, "by", artist)

def total_seconds(a, b):
    """Add two song lengths and hand the sum back."""
    return a + b

greet()
describe("Titanium", "David Guetta ft. Sia")

total = total_seconds(200, 245)
print(total)

print(greet())
print(total_seconds(200, 245) + 100)

print(type(total_seconds).__name__, total_seconds.__name__)
print(total_seconds.__doc__)
you see
Hey, how are you
Titanium by David Guetta ft. Sia
445
Hey, how are you
None
545
function total_seconds
Add two song lengths and hand the sum back.
where beginners trip
  • The def line ends in a colon and the body is indented. Miss either and the file will not run at all — syntax is checked before anything executes.
  • Writing greet where you meant greet() is legal and silent. You handed yourself the machine and never switched it on.
  • A Type 1 function hands back None. So print(greet()) prints the greeting, then prints None underneath it.
  • The def statement must have run before the call does. Call a function above its own def line and you get NameError.
  • Parameters are Ajai's word for the design, arguments for the delivery: "argument is the actual value of a parameter." Keep the two words apart and every error message below reads clearly.

And in memory, def does something you have already met. It builds a function object on the heap — the machine itself, which is the compiled body plus the list of parameter names. Then it binds the name describe to it, exactly the way a = 10 bound a name to an int back in chapter 3. The object is compact, about 160 bytes on this machine. It mostly just points at the separate 240-byte block of compiled instructions that is the body. Then comes the part that surprises everyone: the body does not run. Not one line of it. def executes, the machine gets built and named, and the instructions inside sit shelved, untouched, waiting for a caller.

THE ONE IDEA TO CARRY FORWARD
A function is an object, and def is two tiny acts glued together: build the machine on the heap, then bind a name to it. Everything else in this chapter — arguments, returns, scope, closures — is only what happens after a pair of parentheses finally wakes that shelved body.
Parameters are the slots; arguments are what you drop infigure
the parameter is part of the machine’s design · the argument is what arrives when you press GO describe the machine parameters · built-in empty slots title artist seconds arguments · values at the call "Blinding Lights" "The Weeknd" 200
Fig 01 — The three parameters are empty slots stamped into describe's design the moment def runs. The three arguments only exist at the call, arriving to fill those slots. Design on the left, delivery on the right.
Wait — if the body never runs at def, what does the bare word describe, with no parentheses, actually give me? The machine itself. Python hands you the object, which prints as <function describe at 0x…> — a live thing you can rename or pass around. Only the parentheses call it. So describe is the noun; describe(…) is the verb.
def is a statement, not a rehearsal
Python really does execute the def line the instant it reaches it — but executing it only builds the object and binds the name. The body is compiled and set on a shelf; it stays there, never yet run, until parentheses press GO. Reaching a def is not the same as running what is inside it.
Step through the lifecycle — watch build, then bind, then GO▸ drag me
① reach the def line — nothing built yet source.py def describe(title, artist, seconds): print(line, "-", seconds, "s") describe("Blinding Lights", …, 200) function object · heap params: title, artist, seconds body: compiled — not run yet describe body run?  NO console (nothing printed yet)
① reach def
Drag from ① to ③. At ② the machine appears and the name binds — yet the console stays empty, because the body is still asleep. Only at ③, when the parentheses press GO, does a single line finally print.
Fig 02 — One def, two quiet acts (build, bind), and a body that waits. The call is a separate event — the only one that ever runs your code.
↺ reframe
Stop reading def describe(…) as "here is where the printing happens." Read it as describe = <a machine> — an assignment wearing a costume. The name and the machine are two separate things: one is a label on a shelf, the other is the object sitting on it. Once that split clicks, everything a function can do next follows from it.
first_class.pypython
encore = describe            # a second name for the SAME machine
encore is describe           # True  -> one object, two names
encore("Titanium", "David Guetta ft. Sia", 245)
# Titanium by David Guetta ft. Sia - 245 s

del describe               # drop the original name...
encore("Blinding Lights", "The Weeknd", 200)
# ...still works: the machine outlived the label

Because the machine is just an object, a name is nothing precious to it. You can hang a second name on the very same machine, and both point at one object (encore is describe is True, same id). Delete the original name and the machine lives on, still answering to encore. Even its __name__ stays "describe", because that was baked in at build time, not carried by the label. This is what people mean when they call functions first-class. A function is a value like any other: free to be renamed, stored in a list, or handed to another function. It's a thread we pull hard on later in the chapter.

SYNTAX · the docstring — the manual you staple to the machinetwo shapes and two readers: __doc__ for your code, help() for you
def name(parameter): """One line, imperative: what it does.""" — the FIRST statement, or it is not a docstring body def name(parameter): """One line summary. parameter -- what it is Returns -- what comes back """ body name.__doc__ — the string itself, still there at run time help(name) — prints the formatted manual page, returns None
Type 1The one-liner. Written in the imperative — "Add two lengths", not "This function adds…". It is the summary line help() shows first.
Type 2Summary, blank line, then the detail: each parameter, what comes back, what it refuses. Triple quotes because the string spans lines.
where it livesNot in a comment. It is stored on the function object, in __doc__, which is why tools can read it and a # comment can never be read back.
help()Prints the name, the exact signature, and your docstring. It is your own words coming back — write nothing and help() has nothing to show.
you type
def describe(title, artist, seconds):
    """Print one playlist line for a track.

    title   -- the song title, a str
    artist  -- who made it, a str
    seconds -- how long it runs, an int
    Returns None: this one prints, it does not hand anything back.
    """
    print(title, "by", artist, "-", seconds, "s")

def shout(title):
    "One-liner docstring: hand back the title in capitals."
    return title.upper()

describe("Titanium", "David Guetta ft. Sia", 245)
print(shout.__doc__)
print(describe.__doc__.splitlines()[0])
help(shout)
you see
Titanium by David Guetta ft. Sia - 245 s
One-liner docstring: hand back the title in capitals.
Print one playlist line for a track.
Help on function shout in module __main__:

shout(title)
    One-liner docstring: hand back the title in capitals.
where beginners trip
  • The docstring must be the first statement in the body. Put one line of code above it and it becomes an ordinary string that Python evaluates and throws away.
  • A # comment is not a docstring. Comments are stripped at compile time; the docstring survives on the object and help() can find it.
  • No docstring means name.__doc__ is None, so print(f.__doc__.upper()) raises AttributeError on the None.
  • help(shout) prints and returns None, exactly like print. Writing page = help(shout) leaves page holding nothing.
  • Do not write help(shout()). That calls the machine first — here it is a TypeError for the missing argument, and you never reach the manual.

✗ The myth

def runs the code you wrote inside it. Defining a function and executing its body are the same event.

✓ The reality

def only builds the machine and hangs a name on it. The body sleeps — compiled and shelved — until a pair of parentheses wakes it. A function riddled with runtime bugs defines without a whimper; it breaks only the day you finally call it.
The deeper cut — in what sense is the body already "compiled"?

We keep saying def "compiles the body." That is a labelled shortcut, and here is the truer picture. The bytecode was actually made earlier. When Python first read the whole file, it compiled every function body into a code object and tucked it away as a constant, before a single statement ran. What the def statement does at run time is lighter. It grabs that ready-made code object and wraps it in a fresh function object, stapling on the parameter defaults, the surrounding variables it can see, and the name describe. Then it drops that object on the heap and binds the name. So "def compiles the body" really means "def wraps an already-compiled body."

You can feel the seam. A typo like x = = 5 inside a function you never call still refuses to run the file at all. It dies at compile time, before any def executes, because syntax is checked once, up front. But a reference to a name that does not exist sails straight through def untouched. It only raises the instant you call, because meaning is checked only when the body finally runs. Syntax up front, meaning on demand. That distinction pays off the day you meet closures and decorators.

You have been feeding three arguments in strict left-to-right order, and the slots filled up quietly. But nothing yet forces the call to match the design — so how does each argument find its parameter when the counts line up, and what happens the moment they don't? →

Wait — a function is an object, which means you can pin sticky notes to it. Write describe.genre = "synthpop" and it just works — Python lets you bolt arbitrary attributes straight onto a function, because under the costume it is an ordinary object with room to spare. The machine can carry notes about itself.
THE BODY EXISTS BEFORE def RUNS
def does not compile the body — Python did that the instant it first read the file, tucking the compiled instructions away as a ready-made code object among the module's constants. What the def line does at run time is lighter: it emits one MAKE_FUNCTION instruction that wraps that constant in a fresh function object and binds the name. The blueprint was already on the shelf; def just stamps a serial number on it.
Wait — the whole describe machine is just 160 bytes — and every extra name you hang on it is only an 8-byte arrow, not a copy. encore = describe makes encore is describe return True; a thousand aliases would still point at the one object. Delete the original name and the machine lives on, still remembering it was born "describe".
SYNTAX UP FRONT, MEANING ON DEMAND
Because def never looks inside the body, a function riddled with runtime bugs defines in perfect silence — it breaks only the day you finally call it. But a typo is caught earlier and harder: a single x = = 5 inside a function you never call still refuses to run the entire file, because syntax is checked once, up front, before any def executes.
Trace3 step the machine — idea · code · memory move together
def in slow motion — build the machine, bind the name, then GO
A def statement is two quiet acts: build a function object on the heap, then bind a name to it. The compiled body stays shelved and unrun until a pair of parentheses finally presses GO. Press Next and watch build, bind, alias and call move as one.
build + bindfunction · 160 Bbody runs only on ( )
Three zones — names · heap · rundef · lifecyclecompile
compile · body already → <code object describe>, shelved as a module constant
Name tablemodule
describe
encore
no names bound yet
Heapobjects
function 160 B
params: title, artist, seconds
code: → shelved bytecode
body: shelved
heap empty
Runbody?
body run?NO
console · stdout
(nothing printed yet)
In plain words
Under the hood
CONSTALU · the operation
Program · describe.py
in scope · type
Memory · heap object + names
registers — the running state
heap · function object the machine that got built
names → object
encore is describe → True
what's happening
beat 1 / 1

02How arguments find their parameters

You already met the two sides of a call. The parameters are the named slots you wrote in the def line — empty boxes waiting to be filled. The arguments are the actual values you hand over at the call site. The moment you write add_song(...), Python has to decide which value drops into which slot. It does it by a small, fixed rulebook — not by guessing, not by type, but by three plain mechanisms you can learn in one sitting.

Positional is the default: values fill slots left to right, first argument into the first slot. Keyword lets a value name its own destination. Write seconds=200 and it flies straight to the seconds slot no matter where you put it, order be damned. And a default is a fallback value baked into the def line, used only for a slot the call leaves empty. One traffic rule binds them: positionals first, keywords after. Once you switch to naming, you can't switch back. A bare value sitting behind a named one is a hard error, caught before your code even runs.

SYNTAX · filling the slots — position, name, defaultfive shapes, and the one default that quietly ruins a program
def f(a, b): — two required slots def f(a, b=default): — b optional. Defaults sit LAST, always f(1, 2) — positional: values fill slots left to right f(b=2, a=1) — keyword: each value names its own slot, order free f(1, b=2) — mixed: every positional first, then the named ones def f(a, b=[]): — THE TRAP: one list, built once, shared forever def f(a, b=None): — the fix: None is the sentinel if b is None: b = [] — a brand-new list per call that needs one
Type 1Positional. Terse and fine for two slots. By the fourth, nobody reading the call can tell which value is which.
Type 2Keyword. f(b=2, a=1) lands the same as f(1, 2). The name travels with the value, so order stops mattering.
Type 3Default. Ajai's rule from his notes: "all defaulted parameters should be kept towards the last." Python enforces it — it is a SyntaxError otherwise.
Type 4The mutable default. [] is evaluated once, when def runs, and stored on the function. Every default-taking call shares that one list.
Type 5The sentinel fix. None is immutable, so sharing it is harmless, and if b is None builds a fresh list inside the call — where it belongs.
you type
def add_song(title, artist="Unknown", seconds=180):
    return f"{title} by {artist} - {seconds} s"

print(add_song("Blinding Lights", "The Weeknd", 200))
print(add_song("Blinding Lights", seconds=200, artist="The Weeknd"))
print(add_song("Titanium"))
print(add_song(artist="Sia", title="Titanium"))

try:
    exec('add_song("Titanium", seconds=245, "Sia")')
except SyntaxError as e:
    print("SyntaxError:", e.msg)

def add(song, playlist=[]):        # the trap, run honestly
    playlist.append(song)
    return playlist

print(add("Blinding Lights"))
print(add("Titanium"))
print(add.__defaults__)

def add_safe(song, playlist=None):  # the fix
    if playlist is None:
        playlist = []
    playlist.append(song)
    return playlist

print(add_safe("Blinding Lights"))
print(add_safe("Titanium"))
you see
Blinding Lights by The Weeknd - 200 s
Blinding Lights by The Weeknd - 200 s
Titanium by Unknown - 180 s
Titanium by Sia - 180 s
SyntaxError: positional argument follows keyword argument
['Blinding Lights']
['Blinding Lights', 'Titanium']
(['Blinding Lights', 'Titanium'],)
['Blinding Lights']
['Titanium']
where beginners trip
  • Read lines 6 and 7 of the output twice. The second add call returns two songs because both calls appended to the same list — the one built when def ran.
  • add.__defaults__ proves it from outside the function: the default is not an empty list any more, it is (['Blinding Lights', 'Titanium'],).
  • A positional after a keyword is a SyntaxError, caught before the program starts. Nothing runs, not even the lines above it.
  • def f(a=1, b) refuses too: SyntaxError: parameter without a default follows parameter with a default. Defaults go last, no exceptions.
  • Filling one slot twice — add_song("Titanium", title="Levels") — raises TypeError: add_song() got multiple values for argument 'title'.
calls.pypython
def add_song(title, artist="Unknown", seconds=180):
    return f"{title} by {artist} - {seconds} s"

add_song("Blinding Lights", "The Weeknd", 200)      # by position
add_song("Blinding Lights", seconds=200, artist="The Weeknd")  # by name, reordered
add_song("Titanium")     # Titanium by Unknown - 180 s  (both defaults kick in)
Positionals fill in order · keywords fly to their namefigure
add_song("Blinding Lights", seconds=200, artist="The Weeknd") "Blinding Lights" seconds=200 artist="The Weeknd" slot 1, by order matched by name → title "Blinding Lights" artist "The Weeknd" seconds 200 the blue boxes are the parameter slots — a default would fill any the call left empty
Fig 06 — Positionals fill slots left-to-right; keywords ignore order and fly straight to the slot carrying their name. Same call, same result either way.

That is the whole rulebook, but rules are easier to trust once you've watched them run. Drag through five real calls below. The same three slots stay put. Only what lands in them changes, colour-coded by how it got there.

Drive the call — watch each argument route to a slot▸ drag me
the call: add_song("Blinding Lights", "The Weeknd", 200) title "Blinding Lights" ← positional artist "The Weeknd" ← positional seconds 200 ← positional returns: "Blinding Lights by The Weeknd - 200 s" Three values, three slots, filled strictly by position — the plainest call there is. positional keyword default (from the def line)
1 / 5
Slide through: all-positional, the same call reordered by keyword, both defaults kicking in, the classic slip where a bare 245 lands in artist instead of seconds, and finally the one arrangement Python refuses to even compile.
Fig 06b — The slots never move; the binding does. Green means filled by position, gold by name, blue by a default — and the last form never runs at all.
THE SLIP THE FIGURE JUST SHOWED YOU
add_song("Titanium", 245) raises no error, yet it's almost certainly a bug: 245 filled artist because that's the second slot, and seconds quietly kept its default of 180. This is exactly why long calls read better with keywords — add_song("Titanium", seconds=245) can't miss. Position is terse; names are safe.

So far every value has been a lone thing dropped into a named slot. But a function can also open its arms and accept however many values you throw. Two star-markers in the def line turn overflow into a single collected object. *args scoops up every spare positional argument into a tuple. **kwargs scoops up every spare keyword argument into a dict. Those are the very structures you built by hand in chapters 6 and 7, packed for you now at the instant of the call. (The names args and kwargs are only convention. The stars do the actual work.)

SYNTAX · the star, both ways — gather in the def, spread at the callone symbol, two opposite jobs, decided by which side of the call it stands on
def f(*args): — GATHER the spare positionals into a tuple def f(**kwargs): — GATHER the spare keywords into a dict def f(a, *args, key=d, **kwargs): — the full signature, in its only legal order f(*sequence) — SPREAD a list or tuple into separate arguments f(**mapping) — SPREAD a dict into keyword arguments
Type 1*args is Ajai's XARGS: "packing the value to a tuple." Pass nothing and you still get a tuple — the empty one, ().
Type 2**kwargs is his XXARGS: the keyword pairs arrive as a dict, so .items() from chapter 7 walks them straight away.
Type 3The order is law: plain names, then *args, then keyword-only names, then **kwargs. Nothing may follow **kwargs.
Type 4At the call site the same star reverses. f(*row) spreads a 3-tuple across three slots exactly as if you had typed the three values.
Type 5f(**meta) turns each key: value into key=value. It is how you hand a function a record you already had lying around.
you type
def playlist_length(*songs):
    print("packed:", songs, type(songs).__name__)
    total = 0
    for s in songs:
        total += s
    return total

def tag_song(title, **extras):
    print("packed:", extras, type(extras).__name__)
    for key, value in extras.items():
        print("   ", key, "=", value)

def add_song(title, artist="Unknown", seconds=180):
    return f"{title} by {artist} - {seconds} s"

print(playlist_length(200, 245, 354))
print(playlist_length())
tag_song("Blinding Lights", artist="The Weeknd", year=2019)

row = ("Titanium", "David Guetta ft. Sia", 245)
meta = {"artist": "Avicii", "seconds": 203}
lengths = [200, 245, 354]

print(add_song(*row))
print(add_song("Levels", **meta))
print(playlist_length(*lengths))
you see
packed: (200, 245, 354) tuple
799
packed: () tuple
0
packed: {'artist': 'The Weeknd', 'year': 2019} dict
    artist = The Weeknd
    year = 2019
Titanium by David Guetta ft. Sia - 245 s
Levels by Avicii - 203 s
packed: (200, 245, 354) tuple
799
where beginners trip
  • playlist_length(lengths) without the star packs the list itself into the tuple: ([200, 245, 354],), and the loop then tries to add a list to a number.
  • An empty *args is (), never None. So len(songs) is 0 and the for loop simply runs zero times.
  • Every key in **mapping must be a string that matches a real parameter, or you get TypeError: got an unexpected keyword argument.
  • The names are yours. *songs and **extras work exactly like *args and **kwargs — only the stars carry meaning.
  • Nothing may sit after **kwargs in a def line. It is the last word in the signature, by design.
collectors.pypython
def playlist_length(*songs):        # songs is a TUPLE Python packs for you
    total = 0
    for s in songs:                 # walk the tuple it just built
        total += s
    return total                    # 799 — full story on return next section

playlist_length(200, 245, 354)      # songs == (200, 245, 354)

def tag_song(title, **extras):      # extras is a DICT
    for key, value in extras.items():
        print(title, key, value)

tag_song("Blinding Lights", artist="The Weeknd", year=2019)
Overflow gets collected — extras into a tuple, named extras into a dictfigure
playlist_length(200, 245, 354) 200 245 354 songs = (200, 245, 354) *args → a tuple (chapter 6) tag_song("Blinding Lights", …) artist="The Weeknd" year=2019 extras = {"artist": "The Weeknd", "year": 2019} **kwargs → a dict (chapter 7)
Fig 06c — Spare positionals land in a tuple, spare keywords in a dict. Nothing is dropped, and you never build the container yourself — the stars do.
TYPE THIS · save it, then run itmake_track.py — one constructor-style function, called four different ways
# make_track.py -- one constructor-style function, called four ways

def make_track(title, seconds, artist="Unknown", *, explicit=False):
    """Build one track record.

    title, seconds -- required, usually passed by position
    artist         -- optional, defaults to "Unknown"
    explicit       -- keyword-only: after the bare *, it MUST be named
    """
    return {"title": title, "seconds": seconds,
            "artist": artist, "explicit": explicit}

def show(track):
    mark = "E" if track["explicit"] else " "
    minutes, seconds = divmod(track["seconds"], 60)
    print(f" {mark} {track['title']:<16}{minutes}:{seconds:02d}   {track['artist']}")

# 1 - everything by position, artist falls back to its default
one = make_track("Titanium", 245)

# 2 - all by keyword, in whatever order reads best
two = make_track(seconds=200, title="Blinding Lights", artist="The Weeknd")

# 3 - position for the required pair, name for the keyword-only flag
three = make_track("bad guy", 194, "Billie Eilish", explicit=True)

# 4 - built straight out of a dict the caller already had
row = {"title": "Levels", "seconds": 203, "artist": "Avicii"}
four = make_track(**row)

for track in (one, two, three, four):
    show(track)

print()
print("defaults on the function:", make_track.__defaults__)
print("keyword-only defaults   :", make_track.__kwdefaults__)

try:
    make_track("One Dance", 173, "Drake", True)
except TypeError as e:
    print("TypeError:", e)
   Titanium        4:05   Unknown
   Blinding Lights 3:20   The Weeknd
 E bad guy         3:14   Billie Eilish
   Levels          3:23   Avicii

defaults on the function: ('Unknown',)
keyword-only defaults   : {'explicit': False}
TypeError: make_track() takes from 2 to 3 positional arguments but 4 were given
Save it as make_track.py and run it. One function, four call shapes, and the same kind of record falls out of each. Read the def line left to right, because the order is law. title and seconds carry no defaults, so they are required. artist carries one, so it has to sit after them. Then the bare * draws a line across the signature: everything after it is keyword-only, which is why call 3 must spell out explicit=True. Sit with call 4 for a moment. make_track(**row) takes a dict the caller already had and spreads it into three named arguments — the mirror of the gathering star, doing real work. The two lines near the bottom show where the machine keeps its fallbacks: ordinary defaults live in __defaults__, keyword-only ones in __kwdefaults__, both built once when def ran. Two things to try. Delete the bare * from the def line and watch the last call stop raising: True becomes just another positional value. Then read the TypeError as it stands — takes from 2 to 3 positional arguments but 4 were given is Python telling you, precisely, that the fourth value had nowhere to land.
Wait — if a star in the def line gathers loose values into a tuple, what does a star at the call site do? The mirror image: it scatters a tuple or dict back into separate arguments. Given row = ("Blinding Lights", "The Weeknd", 200), the call add_song(*row) spreads those three across title, artist, seconds exactly as if you'd typed them out; and add_song("Blinding Lights", **{"seconds": 200}) unpacks a dict into keyword arguments. Same symbol, opposite directions — one gathers, one spreads — depending on which side of the call it stands.

One question is easy to get wrong, and getting it wrong causes real bugs. When a value slides into a slot, is the function handed a copy, or the original itself?

✗ The myth

Arguments are copied into the function ("pass by value"), so whatever happens inside is sealed off — nothing a function does to its parameters can ever leak back out.

✓ The reality

No copy is made. The parameter becomes a second name for the very same object — chapter 3's aliasing, happening fresh at every call. Rebinding the name (s = s + 1) only points that one local name elsewhere and is invisible outside. Mutating the shared object (songs.append(...)) is fully visible, because caller and function are holding the same thing. Python calls this pass-by-object-reference.
↺ reframe
Don't picture arguments travelling into the function. Picture the function's parameter names as sticky labels, and at call time each label is slapped onto an object the caller already owns. title isn't a box holding "Blinding Lights" — it's a second tag hanging off the one string object. That's why passing a list lets a function reach back and change the caller's list, but passing a number never surprises anyone: numbers are immutable, so the only thing you can do is re-label.
THE MUTABLE-DEFAULT TRAP
def add(song, playlist=[]) builds one list, once, at def time — and every call that omits playlist reuses that same list. So add("Blinding Lights") then add("Titanium") returns ['Blinding Lights', 'Titanium'], not the fresh ['Titanium'] you expected. The fix is a fixed idiom: default to None, then build the real list inside the body.
safe_default.pypython
def add(song, playlist=None):
    if playlist is None:      # a brand-new list per call that needs one
        playlist = []
    playlist.append(song)
    return playlist
The deeper cut — the full signature order, and why that default is shared

The complete ordering of a parameter list is fixed: plain parameters, then *args, then keyword-only parameters, then **kwargs. Two bare markers let you draw harder lines. Names before a lone / are positional-only (callers may not name them); names after a lone * are keyword-only (callers must name them):

strict.pypython
def add_song(title, /, artist, *, seconds=180):
    return (title, artist, seconds)

add_song("Blinding Lights", "The Weeknd", seconds=200)   # ok
add_song(title="Blinding Lights", artist="The Weeknd")     # TypeError: title is positional-only
add_song("Blinding Lights", "The Weeknd", 200)          # TypeError: seconds is keyword-only

And here is the mechanism the trap above rides on. Default values are evaluated once, at the moment def runs — not rebuilt on each call. Python stores them on the function object itself, in add_song.__defaults__. For an immutable default like 180 you'd never notice. For a mutable one like [], that single stored list is the shared state that leaks between calls. A default isn't a recipe re-run each time. It's one object, made when the def line executes, and reused forever after.

So arguments flow in and settle into their slots. What flows back out when the work is done — and what does Python quietly hand you when a function never says a word? →

NOW WRITE IT YOURSELFthe queue that will not empty — find the bug, then repair it without touching a single call
Here is a function that looks completely reasonable, and is quietly broken: def queue_song(song, queue=[]): queue.append(song); return queue. Call it three times in a row — queue_song("Blinding Lights"), then queue_song("Titanium"), then queue_song("bad guy") — and write down what you expect each call to print before you run anything. Now run it. The third call hands back all three songs, and you never asked it to remember. Your job has three parts. First, prove where the memory lives: print queue_song.__defaults__ after the calls and look at what is sitting in there. Second, repair it so each default-taking call gets a fresh list — and do it without changing any of the three call lines. Third, make sure your repair still lets a caller pass their own list in: queue_song("Faded", mine) must append to mine and nothing else. One hint and no more: the only object safe to use as a default is one that cannot be changed.
show the solution
# the bug, run honestly
def queue_song(song, queue=[]):
    queue.append(song)
    return queue

print("call 1:", queue_song("Blinding Lights"))
print("call 2:", queue_song("Titanium"))
print("call 3:", queue_song("bad guy"))
print("stored on the function:", queue_song.__defaults__)

# the fix: None is the sentinel, the real list is built inside the call
def queue_song_fixed(song, queue=None):
    if queue is None:
        queue = []
    queue.append(song)
    return queue

print()
print("call 1:", queue_song_fixed("Blinding Lights"))
print("call 2:", queue_song_fixed("Titanium"))
print("call 3:", queue_song_fixed("bad guy"))
print("stored on the function:", queue_song_fixed.__defaults__)

# and it still lets a caller pass their own list in
mine = ["Levels"]
print("passed in :", queue_song_fixed("Faded", mine))
print("caller's  :", mine)

# ------------- what it prints -------------
call 1: ['Blinding Lights']
call 2: ['Blinding Lights', 'Titanium']
call 3: ['Blinding Lights', 'Titanium', 'bad guy']
stored on the function: (['Blinding Lights', 'Titanium', 'bad guy'],)

call 1: ['Blinding Lights']
call 2: ['Titanium']
call 3: ['bad guy']
stored on the function: (None,)
passed in : ['Levels', 'Faded']
caller's  : ['Levels', 'Faded']
Wait — no copy is ever made when an argument slides into a slot. Print id(x) inside the function and it is the exact same number as the caller's object — the parameter is a second name for the very thing you passed, not a duplicate. That is why handing a function a list lets it reach back and change yours, while handing it a number never surprises anyone.
THE DEFAULT IS ONE OBJECT — AND YOU CAN WATCH IT
A default is built once, when def runs, and stored on the function itself in __defaults__. So def add(song, playlist=[])'s list is the same list every call — provable from outside: run add("Blinding Lights"), then read add.__defaults__ and watch it already holding (['Blinding Lights'],) before the next call even happens.
Wait — since defaults live in a plain tuple on the function object, you can rewrite them at runtime. Do add_song.__defaults__ = ("The Weeknd", 200) and from then on add_song("Blinding Lights") returns "Blinding Lights by The Weeknd - 200 s" — you changed what "empty" means for every future call, without touching the def line at all.
Trace3 step the machine — idea · code · memory move together
Arguments slot by position — get the order wrong and it lies
Positional arguments fill parameters strictly left to right — by slot number, never by name or meaning. Hand play(245, "Titanium") to def play(title, seconds) and Python silently mis-slots them: title=245, seconds="Titanium", with no error at all — just a confident, wrong answer. Naming the arguments routes them correctly in any order; a bare value after a named one is refused before a line runs.
3 call forms1 silent lie1 SyntaxError
Two values, two slots — routed by position, then by namedef play(title, seconds)call
title
empty
seconds
empty
READY
In plain words
Under the hood
BINDALU · the binding rule
Program · play.py
slots · type
Memory · the call frame
registers — the two slots
frame play() bound slots + return
title
seconds
returns
what is happening
beat 1 / 1

03What comes back: return, or None

A function runs, and then it answers. return is how it answers, and it does two things in the same breath. It stops the call on the spot, and it hands exactly one object back to whoever asked. That second part is the quiet magic. The call expression doesn't merely trigger the work, it becomes the result. Write total = playlist_length(200, 245) and, the instant the call returns, total is simply another name for 445. The parentheses did their job and dissolved. What's left standing where the call used to be is one plain value.

Lean on that word exactly. Not "up to one," not "as many as you like" — one object crosses back, always. And because return also ends the call the moment it fires, any line written after it in the same path is dead on arrival. The function has already left the building. That's not a rule to memorise so much as a consequence of the first job: you can't run more code after you've handed back your answer and gone.

SYNTAX · return — the one object that crosses backfive shapes, and the three roads that all end at None
return value — hand ONE object back, and end the call now return — bare: end the call, hand back None (no return line at all) — the compiler writes `return None` for you return a, b — the COMMA packs one tuple. Still one object x, y = f() — unpack it back into two names answer = f(arg) — the call BECOMES the value it returned if answer is None: — test for nothing with `is`, never with `==`
Type 1One object goes back, and the call ends on the spot. Any line below it on the same path is dead code — the function has already left.
Type 2Bare return is the early exit. It means "stop here, I have nothing worth handing over", and Python supplies None for you.
Type 3No return at all is the same thing again. The function still answers — it answers None, the one nothing-object.
Type 4Two values back is a polite fiction. The comma builds a tuple, so exactly one object still makes the crossing, and unpacking splits it at the far end.
Type 5print shows a value to a human; return hands one to the program. A function that prints its answer gives you None to compute with.
you type
def total(a, b):
    return a + b

def clean(title):
    if not title:
        return                 # bare: nothing worth handing back
    return title.strip().title()

def shout(title):
    print(title.upper())       # prints, returns nothing

def longest():
    return "Titanium", 245     # the comma packs ONE tuple

print(total(200, 245))
print(total(200, 245) + 100)

print(clean("  blinding lights  "))
print(clean(""))

echoed = shout("levels")
print(echoed)

surprise = print("hi")
print(surprise)

pair = longest()
print(pair, type(pair).__name__)
title, seconds = longest()
print(title, seconds)

print(clean("") is None, shout("faded") is None)
you see
445
545
Blinding Lights
None
LEVELS
None
hi
None
('Titanium', 245) tuple
Titanium 245
FADED
True True
where beginners trip
  • x = print("hi") puts None in x. print draws on the screen and hands back nothing — it is not a quiet return.
  • A function that prints its answer instead of returning it looks perfect on screen. Then total = shout(...) + 1 raises TypeError on the None.
  • Lines written after return on the same path never run. No warning, no error — the function was already gone.
  • An if with no else can fall off the end and return None by accident. That is the origin of most surprise-None bugs.
  • Check with is None, not == None. There is only one None in the whole program, so identity is the honest question.
The two jobs of return, in one linefigure
def playlist_length(a, b): total = a + b return total print("done!") ✗ never runs ② return also STOPS the call — the line below is dead ① it hands back exactly one object 445 total = 445 the call expression IS 445 now
Fig 03a — One return, two effects: the value 445 travels back and the name total becomes it (job ①), while the call ends so nothing below the return ever executes (job ②).
EVERY FUNCTION RETURNS SOMETHING
There is no such thing as a function that returns nothing. A call is an expression — it always evaluates to an object, exactly the way 200 + 245 evaluates to 445 — so a call is always legal on the right-hand side of an =. The one escape hatch is a call that raises instead of returning: then control leaves through the exception and no value comes back at all. Short of that, every call hands you an object — the only real question is whether it's worth keeping.

So what does a function hand back when it doesn't say return at all? It still answers — with None, Python's built-in nothing-object. Not a crash, not a blank, not the absence of a value. It's a specific, real object whose entire job is to mean "nothing worth returning happened here." You can catch it in your hands and print it:

silence.pypython
def shout(title):
    print(title.upper())      # shows on screen — but hands nothing back

echoed = shout("Titanium")    # TITANIUM appears here…
print(echoed)                 # None — the silence has a name

surprise = print("hi")        # careful: print is NOT return
print(surprise)               # None — print SHOWS a value, it never hands one back

That last pair is the trap that snares nearly everyone once. print and return feel like cousins, but they live on opposite sides of a wall. print draws characters on your screen for a human to read, then quietly returns None. return hands an object back to the program so more code can use it. A function that prints its answer but forgets to return it looks perfect on screen. Then it gives you None the moment you try to do anything with the result.

TYPE THIS · the same program twiceplaylist_report.py — a flat script, then the same work cut into three named machines
# playlist_report.py -- BEFORE: one flat run of steps, nothing named
playlist = [("Blinding Lights", 200), ("Titanium", 245), ("bad guy", 194),
            ("Levels", 203), ("Faded", 212), ("One Dance", 173)]

total = 0
for title, seconds in playlist:
    total = total + seconds

best_title = playlist[0][0]
best_seconds = playlist[0][1]
for title, seconds in playlist:
    if seconds > best_seconds:
        best_title = title
        best_seconds = seconds

lines = []
lines.append("tracks : " + str(len(playlist)))
lines.append("total  : " + str(total) + " s")
lines.append("longest: " + best_title + " (" + str(best_seconds) + " s)")
print("\n".join(lines))
# playlist_report.py -- AFTER: three named machines, each handing a value back
playlist = [("Blinding Lights", 200), ("Titanium", 245), ("bad guy", 194),
            ("Levels", 203), ("Faded", 212), ("One Dance", 173)]

def total_seconds(tracks):
    """Add every track length and hand the sum back."""
    total = 0
    for title, seconds in tracks:
        total += seconds
    return total

def longest(tracks):
    """Hand back the (title, seconds) pair with the biggest length."""
    best = tracks[0]
    for track in tracks:
        if track[1] > best[1]:
            best = track
    return best

def report(tracks):
    """Build the three lines as one string -- print nothing."""
    title, seconds = longest(tracks)
    return "\n".join([
        f"tracks : {len(tracks)}",
        f"total  : {total_seconds(tracks)} s",
        f"longest: {title} ({seconds} s)",
    ])

print(report(playlist))
tracks : 6
total  : 1227 s
longest: Titanium (245 s)
Type both. Run both. The output is byte-for-byte identical — that is the point, and it is the whole discipline in one line. Refactoring changes the shape of the code and nothing else. Now read what actually moved. In the BEFORE version, total and best_seconds are loose names lying around the module, and the two loops are only distinguishable by reading every line of them. In the AFTER version each job has a name on the side, and the name says what it does before you read the body. Notice what none of the three functions do: none of them print. total_seconds hands back a number, longest hands back a tuple that the caller unpacks in one move, and report hands back a string. Printing happens once, at the very edge of the program, on the last line. That split is what makes the AFTER version testable — you can check total_seconds(playlist) == 1227 without a screen anywhere in sight. Two things to try. Add a seventh track and watch all three lines update with no other edit. Then change report to print its lines instead of returning them, and see how quickly you lose the ability to ask it a question.
TEST FOR NONE WITH is, NOT ==
There is only one None in a running program — every "nothing" is the very same object, a singleton. So the idiomatic, and slightly faster, check is if x is None: — you're asking "is this the nothing-object?" (an identity test), not "does this happen to look equal to it?"

Watch all of this move. Slide through four functions below: one that returns a number, one with a bare return, one with no return at all, and one that hands back a pair. In every case, whatever crosses the bridge is one object. And the name at the call site simply becomes it.

What crosses the bridge — drag through four functions▸ drag me
def playlist_length(a, b): return a + b ↑ evaluates to 445 returns ▸ 445 hands back the call site becomes: total = 445 One int object crosses the bridge — total is now just a name for 445. every call evaluates to exactly one object — silence means None
1 / 4
Slide through all four. The object on the bridge changes — a number, then None twice (a bare return, then no return line at all), then a tuple of two values — but it is always exactly one object.
Fig 03b — The name on the right never does the work; it just becomes whatever single object the function hands back. Give nothing, and that one object is None.
↺ reframe
Stop reading a call as a command and start reading it as a value with extra steps. playlist_length(200, 245) isn't an instruction that "does 445" — after it returns, it simply is 445, and you can drop it anywhere a plain 445 could go: add it, compare it, print it, pass it straight into another call. A function call is a machine for turning arguments into one object, and the object is the whole point.

Which raises the obvious objection. What if you genuinely need two things back — a title and its length? You don't get to break the one-object rule. You bend it. Write return title, seconds and the comma quietly packs both into a single tuple. Remember, it's the comma that builds the tuple, not the parentheses. So still exactly one object makes the crossing. At the other end, chapter 6's unpacking splits it back apart in one clean move:

two_back.pypython
def longest_song():
    return "Titanium", 245        # the comma packs ONE tuple

pair = longest_song()             # pair  → ('Titanium', 245)  — a single tuple
title, seconds = longest_song()   # unpacked back into two names
print(seconds)                    # 245

✗ The myth

A function without a return "returns nothing," so its result is unusable — a dead end you can't assign or pass on.

✓ The reality

It returns None, a genuine object. You can capture it, pass it, and print it — it's just rarely useful. "Returns nothing" is loose talk for "returns None," and knowing the difference is what stops the None-that-should've-been-a-number bug.

Wait — while shout was running, a local name title existed for a few microseconds and then vanished the instant return fired. Where, exactly, did that name live — and what swept it away so cleanly the moment the call ended?
The deeper cut — bare return, falling off the end, and why it's always one object

Three things all funnel to the same None, and it's worth seeing they're truly identical. A bare return, a function whose body just runs off the end, and an empty pass body all hand back the exact same object. You can prove they're the same one, not merely equal, because None is a singleton: bare() is nothing() is True. Underneath, CPython isn't improvising this. Disassemble a silent function on Python 3.12 and the last instruction is literally RETURN_CONST None — the compiler wrote the None in for you. There is no code path out of a function that doesn't return an object. "No return statement" just means "the compiler supplied the return."

And the two-value case is the prettiest proof that "exactly one object" is no white lie. Disassemble return "Titanium", 245 and you don't see two returns or a special multi-value opcode. You see one RETURN_CONST (('Titanium', 245)), the whole tuple built once as a single constant and handed back in a single move. The comma didn't return two things. It manufactured one thing out of two. As for cost, that None is astonishingly cheap. sys.getsizeof(None) is just 16 bytes, and since it's a singleton, every None in your whole program shares that one 16-byte object rather than minting a new one each time.

The local title lived in a private scratch record that Python built for that one call and destroyed the instant it returned — a frame. Frames stack up as calls nest inside calls and vanish in strict last-opened-first-closed order. Next: the call stack — frames that push and pop →

NOW WRITE IT YOURSELFmm_ss(seconds) — the little machine the report is missing
Your report prints total : 1227 s, which is honest and unreadable. A human wants 20:27. Write one function, mm_ss(seconds), that takes a whole number of seconds and returns the string — it must not print anything, because the caller decides where the text goes. Two operators do all the work, and you met both in chapter 4: // gives you the whole minutes, % gives you the leftover seconds. The one real trap is the zero. mm_ss(5) has to come back as "0:05", not "0:5", so the seconds always need two digits — look up the f-string spec :02d, which means "pad this number to width two with zeros". Check yourself against four inputs before you look: 1227, 245, 5 and 0. Then wire it into the type-along above so the total line reads 20:27, and print every track's length beside its title. Last question, and it is the one that matters: why does mm_ss return instead of print? Because a returned string can be printed, joined, or written to a file — and a printed one is already gone.
show the solution
def mm_ss(seconds):
    """Format a whole number of seconds as M:SS."""
    minutes = seconds // 60
    rest = seconds % 60
    return f"{minutes}:{rest:02d}"

print(mm_ss(1227))
print(mm_ss(245))
print(mm_ss(5))
print(mm_ss(0))
print(mm_ss(60))
print(mm_ss(3600))

playlist = [("Blinding Lights", 200), ("Titanium", 245), ("bad guy", 194),
            ("Levels", 203), ("Faded", 212), ("One Dance", 173)]

total = 0
for title, seconds in playlist:
    total += seconds

print("total  :", mm_ss(total))
for title, seconds in playlist:
    print(f"  {title:<16}{mm_ss(seconds)}")

# ------------- what it prints -------------
20:27
4:05
0:05
0:00
1:00
60:00
total  : 20:27
  Blinding Lights 3:20
  Titanium        4:05
  bad guy         3:14
  Levels          3:23
  Faded           3:32
  One Dance       2:53
Wait — ask Python for a brand-new None and it hands you the old one. type(None)() — actually calling the None class — gives back an object where … is None is True. There is exactly one None in the whole running program, and even trying to manufacture another just points you back at it.
Wait — run print(print("hi")) and you see two lines: hi, then None. The inner print draws hi on screen and hands back None; the outer print then dutifully shows that None. It is the cleanest one-liner proof that print shows a value while return hands one back — opposite sides of a wall.
EVERY None IS THE SAME 16 BYTES
Return None a billion times and Python allocates nothing new — sys.getsizeof(None) is just 16 bytes, and every "nothing" in your program shares that one object. The empty tuple gets the same treatment: () is () is True. Some objects are so common Python keeps a single copy and points everyone at it.
THE COMPILER WRITES YOUR RETURN FOR YOU
A function with no return line is not special-cased at run time — disassemble a silent function on Python 3.12 and its last instruction is literally RETURN_CONST None. There is no path out of a function that does not hand back an object; "no return statement" just means the compiler quietly supplied one.

04The call stack: frames that push and pop

Chapter 8 had you build a stack by hand — a pile of plates where you only ever touch the top one. Here is the secret. Your interpreter has been running one of those the entire time, underneath every function you have ever called. It is called the call stack. Once you can see it breathe, functions stop being magic boxes and become exactly what they are: a tidy, self-cleaning pile of paperwork.

Every time you call a function, Python tears off a fresh sheet of scratch paper for that one call: a frame. The frame holds this call's local names — its parameters, and any variable it assigns — plus a bookmark remembering which line to come back to. Calling a function pushes its frame onto the top of the stack. Hitting return pops that frame straight back off. Its local names are wiped, and the value it returned flows down into the frame that was waiting below. Last sheet on is the first sheet off. That's the very same LIFO discipline as the plate-stack from chapter 8, because it is one, wired directly into the interpreter.

That one mechanism quietly answers a question you may not have known to ask. If I call the same function twice, why don't the two calls trip over each other's variables? Because each call got its own sheet of paper. Two frames, two private sets of locals, sitting on the stack at the same time and unable to see across the gap. The n inside one call is simply not the n inside another.

THE ONE IDEA TO CARRY FORWARD
A running program is a stack of frames, one per call still in progress. Calls push, returns pop, and only the top frame is actually running — everything beneath it is paused, holding its place, waiting for a value to drop down from above.

Take the smallest honest example from our playlist. One function turns minutes-and-seconds into a plain count of seconds; another adds two songs together. "Blinding Lights" is 3 min 20 s, so song_seconds(3, 20) is 200; "Titanium" is 4 min 5 s, so song_seconds(4, 5) is 245; together, 445. Watch the three shapes the stack passes through as that runs.

one call before, two calls deep, empty afterfigure
before the call module total = ? two calls deep module total = ? total_seconds a = 200 b = ? song_seconds ← runs m = 4 s = 5 after both return module total = 445 calls PUSH ↑ returns POP ↓
Fig 1 — The top frame (gold) is the one running; every frame beneath it is paused, holding a half-finished line, waiting for a value to flow down. Two calls deep on the left, back down to one on the right.

A snapshot is a still photograph, though, and a stack is a motion picture. Drag the step below through the whole run — eight beats from the first line to the last. Watch each call push a frame. Watch each return pop one and hand its answer down. And watch the highlighted line track exactly which sheet of paper is doing the work.

scrub the run — watch frames push and pop▸ drag me
THE CALL STACK — grows upward ↑ frames: 1 def song_seconds(m, s): return m*60 + s def total_seconds(): a = song_seconds(3, 20) b = song_seconds(4, 5) return a + b total = total_seconds()
step 0 / 7
Calls push, returns pop — the last frame on is the first frame off (LIFO). The gold frame is the one running; the cyan values are its private locals, gone the instant it pops.
Fig 2 — The same run, one beat at a time. Notice that song_seconds is pushed, run, and destroyed twice — the second call gets a brand-new frame, never the recycled first one.
↻ reframe
You never "jump into" a function and leave the caller behind. The caller is right there underneath, frozen mid-line, its half-finished expression holding a slot for the value about to arrive. A program isn't a single point of execution racing around your file — it's a tower of paused work, and only the brick on top is moving.

One precise thing about what a frame actually stores. It does not hold the objects 200 and 245 themselves — it holds the names that point at them. The objects live out on the heap, the open warehouse we met in chapter 0. A frame's locals are just labelled arrows aimed at that warehouse. So when a frame pops, the arrows vanish, but an object only disappears once nothing anywhere still points at it. Chapter 11 draws that border between stack and heap in full. For now, keep the two jobs apart: the stack tracks who is calling whom, the heap holds the things themselves.

Wait — if every call pushes a fresh frame, what happens when a function calls itself? Nothing special: the stack just keeps growing, frame on frame on frame, each with its own private copy of the locals. That is recursion, and the call stack is exactly what makes it work — every level gets a clean sheet, so the levels never overwrite each other. Slide it and watch the tower rise.
a function calling itself — the tower rises▸ drag me
STACK — one frame per call ceiling ≈ 1000 → RecursionError def fact(n): if n == 1: return 1 return n * fact(n - 1) Each call pauses on the last line, waiting for fact(n-1) below it to hand back a value.
depth 4
The green frame at the top is the base casefact(1) returns without calling again, and the tower unwinds, each level multiplying as its child returns. Keep pushing and Python eventually says stop.
Fig 3 — Recursion is not a new mechanism — it is the ordinary push-and-pop, aimed at the same function. The stack's private-frame rule is the whole reason the levels don't clobber one another.

"Eventually says stop" is not a figure of speech. Each frame costs real memory. A runaway recursion — a function that forgets its base case and calls itself forever — would eat the machine alive. So Python draws a hard line: ask sys.getrecursionlimit() and it answers 1000. Let a function call itself with no exit and the tower climbs fast. Then it slams into that ceiling just shy of the thousandth frame, where Python refuses to push another and raises RecursionError. It is not your program crashing randomly. It is the interpreter's seat belt, catching an infinite climb before it takes the whole process down.

DON'T JUST RAISE THE CEILING
You can nudge the limit with sys.setrecursionlimit(), but reaching for it is usually a sign the recursion has no working base case. The ceiling is deliberately set below the level where the real C stack underneath would overflow — and that overflow is a hard crash Python can't catch. A RecursionError is a gift: it turns a fatal crash into a catchable exception. Fix the base case, don't lift the ceiling.

Here is the part that pays for the whole section. You have watched the call stack print itself hundreds of times already — every time your code blew up. That wall of "File ..., line ..., in ..." is not noise. It is a photograph of the live stack at the instant something broke, frame by frame, oldest call first. The traceback is the call stack, made visible. (The raise ValueError("no such song") in the next block is how you throw an error on purpose. Here it's just a reliable way to break something so the stack prints. You'll build exceptions and raise properly in a later chapter.)

boom.pypython
def a_call():  return b_call()
def b_call():  return c_call()
def c_call(): raise ValueError("no such song")

a_call()
# Traceback (most recent call last):
#   File "boom.py", line 5, in <module>   a_call()      ← bottom of the stack
#   File "boom.py", line 1, in a_call     return b_call()
#   File "boom.py", line 2, in b_call     return c_call()
#   File "boom.py", line 3, in c_call     raise ValueError(...)   ← top, where it broke
# ValueError: no such song
↻ reframe — the traceback is a map, not a scolding
Read it like the stack it is. The first frame listed is the floor — <module>, where your program began. Each line below is one frame deeper, one more call still waiting for the one above it. The last frame is the top of the stack: the exact call that was running when it failed. "Most recent call last" is Python telling you plainly — this is the tower, printed floor to ceiling.

✗ The myth

Local variables are stored "inside the function," so two calls to the same function share them, and a value can leak from one call into the next.

✓ The reality

Locals live in the frame, and every call gets its own frame. The function is just the recipe; the frame is the kitchen. Ten simultaneous calls means ten kitchens — ten private copies of every local. The recursion above proves it, with a different n alive at every level.
The deeper cut — is "the stack" a real place in memory?

Saying frames sit on a separate "stack" of memory is a labelled simplification — true to the roles, looser about the geography. In CPython (the standard interpreter), each Python frame is itself a small heap object of type frame. You can even grab the running one with sys._getframe() and follow its f_back link down the whole tower, reading each frame's f_locals as you go. So "the stack" names the strict last-in-first-out discipline those frame objects obey — push on call, pop on return. It's not a fenced-off slab of hardware across town. The behaviour in every figure here is exactly right. Only the literal "separate district" is the convenient lie, and now you know where it bends.

Why can this discipline be so cheap? Because it only ever changes at one end. Pushing a frame is barely more than advancing a single pointer. Popping it is moving that pointer back. No searching, no cleanup crew. That's precisely why local names are fast and self-erasing, and why the real C-level stack has a fixed size that Python's 1000-frame ceiling is carefully set to stay under.

Every frame carries its own private names — so when the same word means one thing inside a function and another thing outside it, which one does Python actually reach for? Next: LEGB, the four-ring search Python runs to look up a name. →

PYTHON TURNS DOWN FREE SPEED
Many languages fold a tail call so a function that ends by calling another reuses one frame instead of stacking a new one. Python deliberately refuses: even() calling odd() calling even() twenty-thousand deep really stacks twenty-thousand frames and hits RecursionError. Every frame is kept on purpose — so the traceback stays an honest, complete map of how you got here.
Wait — the recursion limit is 1000, but count the frames and Python actually stops you at 999 — one sheet shy of the number. The ceiling sits just below the real C-stack cliff underneath, and that overflow is a hard crash Python cannot catch. So RecursionError is not your program failing; it is the seat belt tightening a frame early.
Wait — the 1000-frame ceiling is about frames, not arithmetic. A recursive factorial gives up near fact(1000) — but the same maths written as a loop sails straight past: math.factorial(2000) is a 5,736-digit number, computed instantly, so big Python will not even print it without lifting its own 4,300-digit safety cap. The stack ran out of paper; the numbers never blinked.
Wait — a recursion's frame tower can literally build a number. Define to_binary(n) that returns "" at 0 and otherwise to_binary(n // 2) + str(n % 2). Call to_binary(6) and it stacks a frame for 6, then 3, then 1 — one per bit — and as those frames pop in LIFO order they glue on "1", then "1", then "0" to spell "110", which is 6 in the base-2 you met back in chapter 0. The stack isn't an abstraction here; it's the thing assembling the digits.
the same tiny answer — recomputed until the machine drowns▸ drag me
one call — fib(n) — but how many times does the machine actually run fib() to answer it? fib(35) THE ANSWER fib(n) 9,227,465 TIME · ~100 ns / CALL 3.0 s ≈ a slow, awkward pause TIMES THE MACHINE RAN fib() FOR THAT ONE NUMBER 29.9M fib() calls · almost all repeats Cache each answer once and it collapses to 36 computed answers — a 829,464× cut. Same number, none of the repeats.
fib(35)
Naive fib(n) calls itself twice, so its frame tree branches — and each branch re-solves subproblems the other branch already solved. The depth is only n (the tower from Fig 3 never gets tall), but the breadth — the total number of calls — doubles roughly every step. Drag to fib(40) and the machine runs fib() a third of a billion times to hand back a nine-digit number it could have found in forty steps.
Fig 4 — Recursion’s hidden cost is not stack height, it is repeated work. The same fib(2) — a trivial 1+0 — is recomputed fib(n−1) times. This is exactly the waste that memoization (Chapter 12) erases by remembering each answer once, turning an exponential tree back into a straight line.
Trace3 step the machine — idea · code · memory move together
The call stack breathing — frames push on call, pop on return, value flows down
A running program is a self‑cleaning tower of frames: each call pushes a fresh sheet of private locals on top; each return pops it and lets its answer fall into the paused frame beneath — strict last‑in, first‑out. Press Next and watch the tower breathe.
stack · LIFOdepth 1→3→1total 445 s
The frame tower — newest on top, module at the basecall stack · LIFOrun
frames: 1
module
total_seconds()
song_seconds()
▲ newest frame on top · module frame at the base
In plain words
Under the hood
RUNALU · call & return
Program · playlist.py
in scope · type
Memory · the call stack
registers — the running state
frames top → bottom · each with its locals
what's happening
beat 1 / 1

05LEGB: where Python looks up a name

You type a name — plays, len, playlist — and in that instant Python has to decide which of the thousands of things in a running program you actually meant. It does not guess, and it does not rummage everywhere at once. It walks a fixed list of exactly four places, always in the same order, and stops dead the moment it finds a match. That list has a name you'll keep for life: LEGB.

Local — the frame of the function running right now. Enclosing — the names an outer def captured for a def nested inside it. Those live not in the outer frame, which may be long gone by the time the inner one runs, but in a shared cell the inner one can still reach. Global — the module: names defined at the top level of the file, like our playlist. Built-in — the names Python ships and you never had to write: len, print, range, sum. Four rooms, one order, first hit wins. Search all four and come up empty, and Python stops apologising and raises a NameError.

⚠ MOST BEGINNERS THINK…it was called from inside party() — so it sees party's names… right?
You just watched the stack, so the belief feels earned: when party() calls report(), party's frame is sitting right beneath it — paused, alive, its names intact. Surely a function can read the variables of wherever it was called from; whoever presses GO shares their names with the machine.
TYPE THIS — 10 SECONDS
>>> mood = "calm"
>>> def report():
...     print("mood is", mood)
...
>>> def party():
...     mood = "hype"      # the caller's own mood, alive on the stack
...     report()           # GO is pressed from INSIDE party
...
>>> party()
>>> del mood               # now remove the module's mood entirely...
>>> party()                # ...party's "hype" is STILL one sheet below
mood is calm
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "<stdin>", line 3, in party
  File "<stdin>", line 2, in report
NameError: name 'mood' is not defined
A function resolves a name through the rooms wrapped around where its def was writtenLEGB, and the caller's frame is not one of the four — which is why report walked straight past party's "hype" to the module's "calm", then died rather than peek one sheet down the stack.
THE ONE IDEA TO CARRY FORWARD
A name is not a box you own — it's a question asked of four rooms, in a fixed order, answered by the first room that has it. Everything strange about scope, every bug in this section, is just this search running exactly as designed.

Reading names this way is quiet and intuitive. Right up until the one sharp edge that catches every Python programmer exactly once. Here it is. The instant you assign to a name anywhere inside a function — the first line, the last line, a line that never even runs — that name becomes Local for the entire function, top to bottom. Watch it bite:

scope.pypython
plays = 0                      # a global name — the whole app's play count

def play_song(title):
    plays = plays + 1          # UnboundLocalError — not the error you expected
    print("now playing", title)

Read that and your gut says: "but plays is right there, it's 0, just add one." Python disagrees, and the reason is beautiful once you see it. Python sorts every name into local-or-not when it compiles the def, before a single line has run. It scans the whole body, spots plays = …, and stamps plays as Local then and there. So at run time the read half of plays = plays + 1 doesn't fall through to the global. It looks in the function's own local slot — which is real, reserved, and still empty. Reaching into an empty local slot is exactly the UnboundLocalError you get. The global plays = 0 was never even consulted. The name had already been claimed by the local room.

Why the read fails: stamped local at compile time, empty at run timefigure
module: plays = 0 (never consulted below) ① COMPILE — scan the whole body first def play_song(title): plays = plays + 1 print(plays) locals = { plays } plays stamped LOCAL — for the whole function ② RUN — the read half reaches in first plays = plays + 1 read plays → look in the local slot local plays ∅ unbound UnboundLocalError the slot exists, but holds nothing yet
Fig 05 — The assignment doesn't just store a value later — at compile time it reclassifies the name. By the time the line runs, plays is a local slot that hasn't been filled, so the read on the right of = fails. The global plays = 0 sits untouched the whole time.

Once you know the cause, the two cures are obvious. If you genuinely mean to rewrite the module's plays, say so out loud with global. That keyword tells the compiler "don't stamp this local — the real one lives in the module." And when the name you mean to rewrite lives in an enclosing function rather than the module, there's a matching keyword for that too. You won't feel the need for it until closures start remembering things, two sections down, so we'll meet it there. But most of the time the cleanest fix is to touch no outer name at all: take the value in, hand a new value back.

SYNTAX · scope as a writer — local by default, outward by declarationfive shapes: the read that just works, the write that bites, and the two keywords that fix it
read a name you never assign — no keyword needed: L → E → G → B finds it name = value — inside a def: LOCAL, for the WHOLE body def f(): global name — "the one in the module" — declare BEFORE you assign name = value def outer(): def inner(): nonlocal name — "the one in the enclosing def" (closures) name = value container.append(x) — MUTATES the shared object. No assignment, no keyword
Type 1Reading is free. A function that only reads plays finds the module's copy through the normal L→E→G→B walk, no declaration required.
Type 2Assigning claims the name. One plays = … anywhere in the body makes plays local everywhere in that body — decided at compile time, before a line runs.
Type 3global cancels that claim and points the write at the module. It must appear before the assignment, and it is a promise you are changing shared state.
Type 4nonlocal is the same move aimed one ring in: the name in the enclosing function, not the module. Without it the inner def just makes its own local.
Type 5Mutation is not assignment. playlist.append(x) changes the object both names share, so it needs no keyword — and it reaches the caller anyway.
you type
plays = 0

def show_plays():
    print("read only, no assignment:", plays)

def bump_broken():
    plays = plays + 1          # claims `plays` as local, so the read fails

def bump_global():
    global plays
    plays = plays + 1

def next_count(plays):         # the clean fix: in through a slot, out via return
    return plays + 1

def make_counter():
    count = 0
    def bump():
        nonlocal count
        count += 1
        return count
    return bump

show_plays()

try:
    bump_broken()
except UnboundLocalError as e:
    print("UnboundLocalError:", e)

bump_global()
bump_global()
print("after two bumps, plays =", plays)

print("next_count(7) =", next_count(7), "| global plays still", plays)

tick = make_counter()
print(tick(), tick(), tick())

print(len("Titanium"))
you see
read only, no assignment: 0
UnboundLocalError: cannot access local variable 'plays' where it is not associated with a value
after two bumps, plays = 2
next_count(7) = 8 | global plays still 2
1 2 3
8
where beginners trip
  • plays = plays + 1 with no global raises UnboundLocalError, not NameError. The local slot exists; it is simply still empty.
  • The claim covers the whole body. Even an assignment on the last line makes the name local on the first — the compiler read the body before you ran it.
  • global after the first assignment is a SyntaxError: name is assigned to before global declaration. Declare it at the top of the body.
  • global is a smell, not a sin. It hides state from every caller and makes the function untestable in isolation. Prefer next_count: take it in, hand it back.
  • Shadowing is legal and quiet. Name a local len and the built-in is unreachable in that function — Local is searched first, and first hit wins.
fixes.pypython
plays = 0

# option A — opt in to the global, and mean it
def bump_global():
    global plays
    plays = plays + 1          # after two calls, plays == 2

# option B — never touch the global; take it in, give it back
def next_count(plays):
    return plays + 1          # the caller decides where the result goes
global is a loaded weapon
A function that rewrites a global has invisible side effects: every caller now shares hidden state, and testing it means rebuilding the whole world around it first. Reach for global only when you truly must. Prefer to return the new value and let the caller decide where it lands — option B above has no spooky action at a distance.

Now let's watch the search itself run. Here's a tiny nested program: a play function tucked inside a make_player function, over a module-level playlist. Four different rooms hold the four names its body uses. Calling make_player(5) then play(200) returns 207 — that's 200 + 5 + 2. Drag the control below and follow Python's eyes as it walks L → E → G → B, crossing off each room that misses until one hits.

player.pypython
playlist = [200, 245]             # G — "Blinding Lights", "Titanium"

def make_player(bonus):           # bonus lives in the ENCLOSING frame
    def play(seconds):            # seconds is LOCAL to play
        return seconds + bonus + len(playlist)
    return play
Look up a name — watch the LEGB search stop at the first hit▸ drag me
B · Built-in — len, print, range, sum … · G · Global (module) — playlist = [200, 245] · E · Enclosing (make_player) — bonus = 5 · L · Local (play frame) · seconds = 200 return seconds + bonus + len(playlist)
seconds
Python searches L → E → G → B and stops at the first hit; a crossed room () was searched and missed, a dim room was never reached. Four misses = NameError. Notice Global and Built-in compile to the same instruction — the deeper cut explains why.
Fig 05b — The rings are drawn nested because the search really is nested: it starts in the smallest room it's standing in and only widens outward. The green room is where the name lives; the tag beside the result is the actual bytecode Python emits to fetch it.
↺ reframe
Stop picturing a variable as a box with your value inside it. Picture a name as a lookup — a question Python re-asks every time the line runs, answered by the innermost room that happens to hold that name today. That's why the same word plays can mean the global on one line and a local on the next: nothing moved, the question just got a different first answer.
Wait — if Built-in is only the last room searched, what happens if I name a variable len? Exactly what LEGB predicts: your len is found in an earlier room and Python never reaches the built-in one. Write len = 999 at module level and the next len([200, 245]) dies with TypeError: 'int' object is not callable — you shadowed the tool with an integer. The built-in isn't gone; it's just been out-voted by a closer room. (del len removes your shadow and the real one answers again.)

✗ The myth

"Python hunts for a name everywhere — all my variables, imports, and built-ins — and somehow finds the right one."

✓ The reality

It checks exactly four places in one fixed order, L→E→G→B, and takes the first match without looking further. No global search, no cleverness — just a short, ordered walk that a beginner can run in their head.

The deeper cut — "Python searches four rooms" is a useful lie; here's the machinery

"Python searches four rooms at run time" is a labelled simplification — the right picture to reason with, and we'll refine it exactly once, here. In truth, only two of those rooms are ever searched while your program runs. When the compiler builds a function, it has already decided, per name, which room it belongs to. It bakes that decision into a different bytecode instruction for each scope. Disassemble our play and you can read the verdict directly: seconds compiles to LOAD_FAST (a Local — a numbered slot, no search at all), bonus to LOAD_DEREF (an Enclosing name, reached through a shared cell), and both playlist and len to LOAD_GLOBAL.

That last line is the twist worth keeping: Global and Built-in are the same instruction. LOAD_GLOBAL looks up the name in the module's dictionary first, and only if it misses does it fall through to the built-ins dictionary. So the "B" room isn't a separate search. It's the fallback of the "G" search. Local and Enclosing aren't dictionary searches at all. They were resolved to slots and cells before the function ever ran. The clean four-letter mnemonic LEGB is exactly right about order and outcome, which is all you need to predict every scoping bug. And now you also know the mechanism underneath it: two static bindings, then one dictionary lookup with a built-in safety net.

We keep saying "def just builds an object and binds a name." So play is a value like any other — you can hand the whole machine to someone else, not just the number it spits out. Next: functions are values you can pass around. →

ERROR CLINICyou will meet these — decoded
3 min 20 s
4 min 5 s
Traceback (most recent call last):
  File "C:\tmp\minutes.py", line 4, in <module>
    total = song_minutes(200) + song_minutes(245)
            ~~~~~~~~~~~~~~~~~~^~~~~~~~~~~~~~~~~~~
TypeError: unsupported operand type(s) for +: 'NoneType' and 'NoneType'
Look at the two lines above the traceback: both calls printed perfectly, and then the + was handed two Nones. song_minutes ends in print(...) with no return, so each call drew on the screen for a human and evaluated to None for the program. The traceback is really pointing at where the nothing was used — one line, sometimes a whole file, away from the function that forgot to hand its answer back.
see NoneType in an arithmetic error → hunt upstream for a function that prints instead of returning
Traceback (most recent call last):
  File "C:\tmp\lineup.py", line 4, in <module>
    line = add_song("Blinding Lights")
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^
TypeError: add_song() missing 2 required positional arguments: 'artist' and 'seconds'
Nothing in the body ever ran. Python matched your one argument against the def add_song(title, artist, seconds) line before entering the machine, found two required slots still empty, and refused at the door. It is really reading the def line back to you — naming, in order, the exact slots that went unfilled.
read the message as a checklist, then fill the slots by name: add_song("Blinding Lights", artist="The Weeknd", seconds=200) cannot land wrong
Traceback (most recent call last):
  File "C:\tmp\leftover.py", line 6, in <module>
    print(total)
          ^^^^^
NameError: name 'total' is not defined
total existed — for exactly one call. It was a local on total_seconds' frame, and the return popped that frame and wiped its names. The 445 crossed back as an object, but the call site — a bare total_seconds(200, 245) — never caught it, so by line 6 no name total exists anywhere. It is really saying: locals die with their frame; only the returned object survives the pop.
catch what crosses back: total = total_seconds(200, 245) — the value survives the pop, the name never does
NOW WRITE IT YOURSELFpredict the scope — four functions, one module, and only one of them explodes
Two module-level names to start: bonus = 10 and playlist = ["Titanium"]. Below them, four tiny functions. A is def a(): return bonus + 1. B is def b(): total = bonus + 1; bonus = 99; return total. C is def c(): bonus = 99; return bonus. D is def d(): playlist.append("Levels"); return len(playlist). Before you run a single line, write down four answers: what does each call return, and what are bonus and playlist afterwards? Take your time on B, because it is the one that catches everybody. The read of bonus happens on the line above the assignment, and your gut will insist that must be fine. It is not, and the reason is the whole section: the compiler scanned the body first and stamped bonus local for every line of it. Then ask the sharper pair of questions. Why does C assign to bonus and leave the module's 10 completely untouched? And why does D change the module's list without a global anywhere in sight, when C could not change a number? One word separates them, and it is not scope.
show the solution
bonus = 10
playlist = ["Titanium"]

# A - reads a global it never assigns: the LEGB walk finds it, no keyword needed
def a():
    return bonus + 1

# B - assigns bonus ANYWHERE, so bonus is local for the WHOLE body.
#     The read on the line above the assignment hits an empty local slot.
def b():
    total = bonus + 1
    bonus = 99
    return total

# C - a local named bonus, born and dead inside the call. The module's 10 never moves.
def c():
    bonus = 99
    return bonus

# D - no assignment at all: it MUTATES the object both names share.
def d():
    playlist.append("Levels")
    return len(playlist)

print("A:", a())

try:
    print("B:", b())
except UnboundLocalError as e:
    print("B: UnboundLocalError:", e)

print("C:", c(), "| module bonus still", bonus)
print("D:", d(), "| module playlist now", playlist)

# ------------- what it prints -------------
A: 11
B: UnboundLocalError: cannot access local variable 'bonus' where it is not associated with a value
C: 99 | module bonus still 10
D: 2 | module playlist now ['Titanium', 'Levels']

# The word that separates C from D is ASSIGNMENT.
#   C assigns: the name is claimed as local, and the module's name is untouched.
#   D mutates: no name is claimed, so it edits the one object the caller can see.
# To make B work, choose one: `global bonus` (and mean it), or pass bonus in
# as a parameter and return the new value. The second has no hidden state.
THE ROOM YOU NEVER FURNISHED
The "B" in LEGB holds 158 names you never typed — len, print, sum, range and 144 other callable tools, plus 70 ready-made exception types like ValueError. Every one is found only after Local, Enclosing and Global have all missed. The room is always there, fully furnished and last in line.
Wait — a comprehension — that [i for i in range(3)] shorthand for building a list, which you'll meet properly next chapter — gets its own private scope. Run it and then ask for iNameError, it never leaked out. Yet a plain for i in range(3): pass leaves i sitting at 2 afterward. Same-looking loop, opposite result: the comprehension quietly runs in a room of its own.
WHY LOCALS ARE LITERALLY FASTER
Reading a local is not a search — the compiler turned it into a numbered slot, so LOAD_FAST is a plain array index. A global is a dictionary lookup by name. The gap is real and measurable, which is exactly why hot loops sometimes copy a global into a local first: same value, cheaper address.
Trace3 step the machine — idea · code · memory move together
Assign it anywhere, it's local everywhere — the UnboundLocalError trap
One assignment to plays inside the function makes plays local for the whole body — decided at compile time, before a line runs. So reading it first finds an empty local slot and raises UnboundLocalError, even though a module global plays = 0 sits right there. Press Next and watch the read miss the global entirely.
compile-time scopelocal UnboundLocalError
Where does the name plays resolve?compile → runmodule
LLocal · play_song()
EEnclosing · none here
GGlobal · moduleplays = 0
BBuilt-inlen · print
no frame yet
In plain words
Under the hood
OPALU · the operation
Program · scope.py
Memory · frames & the module
play_song frame · locals
module · globals
beat 1 / 1

06Functions are values: pass the machine, not its output

Everything you've handed a function so far has been data — a number, a string, a tuple of a song and its length. Now watch the floor tilt. In Python a function is itself just another value: an object like any other, with a type, an identity, and an address in RAM. You can drop it in a list, pin a second name on it, stash it as a value in a dict, or post it to another function as an argument. Python's word for this is first-class. A function gets the exact same rights as a plain 200. And that one fact quietly explains the mysterious key= you first met when you sorted a playlist back in chapter 6.

Say you want the playlist ordered by length, shortest first. You write the rule for what to sort by as an ordinary function. Then you hand that function to sort. The function itself, mind you, not a call to it:

sort_by.pypython
playlist = [("Blinding Lights", 200), ("Titanium", 245), ("bad guy", 194)]

def by_seconds(song):
    return song[1]                # pull out the length field

playlist.sort(key=by_seconds)      # hand over the machine — no parentheses
# playlist is now ordered shortest-first:
# [("bad guy", 194), ("Blinding Lights", 200), ("Titanium", 245)]

Notice what's missing: no parentheses after by_seconds. That absence is the entire idea, and it trips almost everyone the first time.

SYNTAX · handing over a machine — key= and the one-expression lambdafive shapes: the bare name, and the nameless function you build right at the call
seq.sort(key=function) — the NAME, no parentheses. sort calls it, once per item sorted(seq, key=function) — same rule, and a NEW list comes back lambda parameter: expression — a whole function in one line, born with no name sorted(seq, key=lambda s: s[1]) — the same machine, built at the call site and forgotten max(seq, key=lambda s: s[1]) — key= works the same on max, min and sorted
Type 1A bare name hands over the machine. Add () and you hand over its answer instead — usually an error, always the wrong thing.
Type 2key= is called once per item and the return value is what gets compared. The items themselves are never modified.
Type 3lambda is a def with the ceremony stripped off: one expression, returned automatically, no name. Full treatment in chapter 10.
Type 4Same object either way. type(f) is function for both; only __name__ differs, and the lambda's is literally <lambda>.
Type 5A minus sign reverses the order without a second pass: key=lambda s: -s[1] sorts longest first by comparing negated lengths.
you type
playlist = [("Blinding Lights", 200), ("Titanium", 245), ("bad guy", 194)]

def by_seconds(song):
    return song[1]

print(sorted(playlist, key=by_seconds))
print(sorted(playlist, key=lambda song: song[1]))
print(sorted(playlist, key=lambda song: song[0]))
print(sorted(playlist, key=lambda song: song[0].lower()))
print(sorted(playlist, key=lambda song: -song[1]))
print(max(playlist, key=lambda song: song[1]))
print(sorted(["Levels", "bad guy", "One Dance"], key=len))

f = lambda song: song[1]
print(type(f).__name__, f.__name__, by_seconds.__name__)
print(f(("Faded", 212)))

try:
    sorted(playlist, key=by_seconds())
except TypeError as e:
    print("TypeError:", e)
you see
[('bad guy', 194), ('Blinding Lights', 200), ('Titanium', 245)]
[('bad guy', 194), ('Blinding Lights', 200), ('Titanium', 245)]
[('Blinding Lights', 200), ('Titanium', 245), ('bad guy', 194)]
[('bad guy', 194), ('Blinding Lights', 200), ('Titanium', 245)]
[('Titanium', 245), ('Blinding Lights', 200), ('bad guy', 194)]
('Titanium', 245)
['Levels', 'bad guy', 'One Dance']
function <lambda> by_seconds
212
TypeError: by_seconds() missing 1 required positional argument: 'song'
where beginners trip
  • key=by_seconds() runs the machine now, before sort is even entered, so you get a TypeError about a missing argument and never reach the sorting.
  • Lines 3 and 4 of the output differ, and that is not a bug. Raw title order puts every capital before every lowercase letter, so "bad guy" lands last until you add .lower().
  • A lambda holds one expression, never a statement. No return, no if block, no loop — the moment you want those, write a def.
  • Naming a lambda (f = lambda s: s[1]) works but wastes the point of it. Its __name__ stays <lambda>, which is what your traceback will show you.
  • key= takes any callable, including Python's own: key=len passes the built-in machine itself — still no parentheses.
↺ reframe
The parentheses were never part of the name. by_seconds and by_seconds() are two different acts. by_seconds is a name that evaluates to the function object — the machine, sitting there cold, switched off. The () is a separate call operator that switches the machine on and evaluates to whatever it hands back. So key=by_seconds gives sort the machine to run once per song; key=by_seconds() would try to run it right now, at the call site — and blow up before sort has even started.
Hand sort the machine, or hand it the output?▸ drag me
you write: playlist.sort( key = … ) key = by_seconds () the machine travels to sort, untouched the machine song → song[1] sort calls it once per song, then orders them ✓ [194, 200, 245] the () fires now by_seconds() it runs before sort, with no song to work on ✗ TypeError: missing 'song'
the machine (key=by_seconds)
Slide right to add the two characters that change everything: (). On the left you hand sort the machine to run per song; on the right the call fires first and sort is left holding nothing it can run.
Fig 06a — A bare name passes the function object; parentheses run it. Only one of these hands sort something it can call.
Wait — if a function is "just a value," what does Python actually keep under the name by_seconds? Ask it. type(by_seconds) answers <class 'function'>, and callable(by_seconds) is True while callable(200) is False. Assign a second name, metric = by_seconds, and metric is by_seconds comes back True — same object, two labels, one identity. A function is an object whose single party trick is that you're allowed to put () after it.

When the machine is a single expression and needs no name of its own, you can build it right at the call site with lambda. Read lambda song: song[1] straight across. lambda announces a tiny function, song is its one parameter, and whatever the expression after the colon comes out to is returned automatically — no def, no return, no name. It's the very same kind of object by_seconds is, just born anonymous and thrown away after one use:

inline.pypython
# same order, machine built inline and forgotten after
playlist.sort(key=lambda song: song[1])

# a lambda is the SAME kind of object as a def'd function
f = lambda song: song[1]
type(f)              # <class 'function'>   — identical type
f.__name__           # '<lambda>'           — just born nameless
by_seconds.__name__  # 'by_seconds'

# because functions are values, a dict can be a menu of them
order_by = {"length": by_seconds, "title": lambda s: s[0]}
playlist.sort(key=order_by["length"])   # look up a machine by name, then hand it over

✗ The myth

A lambda is a stripped-down, second-class "mini function" — some lightweight cousin of a real def, with less power under the hood.

✓ The reality

It builds the exact same object — type(f) is function either way, verified above. The only real differences: a lambda has no name of its own (__name__ is '<lambda>') and its body must be a single expression. Same machine, born without a nameplate.

The deeper cut — what "run it now" actually does

"key=by_seconds() hands sort a single answer instead of a machine" is a useful half-truth — clean enough to steer by, so let's sharpen it to the metal. What really happens is that () fires before sort is ever called. And by_seconds() with no song raises TypeError: by_seconds() missing 1 required positional argument: 'song'. You never reach sort at all. And even in the case where the inner call could succeed — picture a zero-argument function — sort would then be handed that call's return value (say the number 200) as its key. It chokes a different way. key has to be callable, so sort tries to run 200(song) per item and hits TypeError: 'int' object is not callable. Two different explosions, one root cause: parentheses run the machine, a bare name passes it. That single distinction is the whole section in a line.

Here's a concrete itch. You want a plays() that returns 1, then 2, then 3 — a counter that remembers across calls. A plain local won't do it: every call gets a fresh frame, so the count resets to 0 each time. A module global works but leaks — every caller now shares one hidden number, spooky action at a distance. A whole class (chapter 12) is a lot of ceremony for a single integer. What you actually want is private state that lives between calls and belongs to no one else. Here is the move that gives it to you. Define a function inside another function and return the inner one. The inner function keeps using a name from the outer function's frame, even after that frame has finished and popped. The compiler sees this coming at compile time. It marks that name for capture, so instead of a plain frame slot the value is born in a small heap-allocated box called a cell, which the inner function grips for as long as it lives. An inner function carrying captured variables like this is a closure. And nonlocal is the keyword that lets it rebind a captured name instead of accidentally shadowing it with a fresh local:

counter.pypython
def make_counter():
    count = 0                  # lives in make_counter's frame
    def bump():
        nonlocal count         # rebind the ENCLOSING count, don't make a new local
        count += 1
        return count
    return bump                # hand the inner machine out; the frame then pops

plays = make_counter()
plays()   # 1
plays()   # 2
plays()   # 3   — the count survived, with no global and no class
The frame is gone — the machine kept what it needed▸ drag me
make_counter() returned bump, then its frame popped — yet count lives on make_counter() frame POPPED — only its stack ref goes plays = the inner machine carries the cell everywhere it goes on the heap all along heap cell count = 0 closure grip plays() — not called yet press it again and the cell climbs a second make_counter() call: count = 0 its own private backpack — the slider never touches this one
0 calls
Each press runs bump(), which does count += 1 inside the surviving cell. The number climbs and stays climbed — that's state, with no global and no class in sight. The counter on the right proves each make_counter() gets its own cell.
Fig 06b — The captured count was born in a heap cell — the compiler saw the capture coming (make_counter.__code__.co_cellvars is ('count',)), so it never lived in the frame to begin with. When the frame pops it drops only its stack reference; the cell, gripped by the returned function, lives on. Private, persistent state — no class required.
A closure is a function with a backpack
It carries the enclosing variables it needs everywhere it goes. Every fresh make_counter() packs a new backpack, so two counters never share a count — call one and the other doesn't budge. It's the lightest way in the whole language to give a function private, persistent state; when that state grows into many fields and behaviours, you'll graduate to a class (chapter 12).
The deeper cut — the cell is real, and it's cheap

The backpack isn't a metaphor Python invents for teaching — it's a concrete object you can hold. After plays = make_counter(), the captured names live in plays.__closure__, a tuple of cells, and plays.__closure__[0].cell_contents is the live count. Nothing is really "rescued at the last second," either. The compiler spots the capture ahead of time: make_counter.__code__.co_cellvars is ('count',), and the inner plays.__code__.co_freevars is ('count',). So count is stored in a heap cell from the very start, ready to outlive the frame. (An ordinary function that captures nothing has __closure__ equal to None.) Drop the nonlocal and Python treats count += 1 as a brand-new local, reads it before it's assigned, and raises UnboundLocalError. The nonlocal keyword is what aims the write back at the cell. And "lightest" is literal: that cell weighs sys.getsizeof ≈ 40 bytes on this Python, while an everyday object drags a per-instance __dict__ of a few hundred bytes more. (A class with __slots__ closes the gap, but you rarely reach for that until you're truly counting bytes.)

You can now hand a machine to sort. Next chapter you hand machines to map and filter, collapse whole loops into a single-line comprehension, and wire up sequences so lazy they compute each value only at the instant you ask for it — and barely touch memory doing it →

TYPE THIS · the capstone, from Ajai's 99 Projectsdice_roll.py — his Dice Roll Simulation, rebuilt so the functions are the star
# dice_roll.py -- Ajai's Dice Roll Simulation, rebuilt so the functions are the star
import random

def roll(sides=6):
    """One roll of one die: a whole number from 1 to sides, inclusive."""
    return random.randint(1, sides)

def simulate(rolls, sides=6):
    """Roll `rolls` times. Hand back a dict of face -> how often it came up."""
    counts = {face: 0 for face in range(1, sides + 1)}
    for _ in range(rolls):
        counts[roll(sides)] += 1
    return counts

def summarize(counts):
    """Turn the counts into report lines. Prints nothing -- it returns."""
    rolls = sum(counts.values())
    lines = []
    for face, times in counts.items():
        bar = "#" * round(times / rolls * 60)
        lines.append(f"  {face}  {bar:<14}{times:>4}  {times / rolls:6.1%}")
    top = max(counts, key=counts.get)
    lines.append(f"  {rolls} rolls | most common face: {top} ({counts[top]} times)")
    return lines

random.seed(9)          # same seed, same run -- so your screen matches this page

print("one roll :", roll())
print("one roll :", roll())
print("one d20  :", roll(20))
print()

counts = simulate(600)
for line in summarize(counts):
    print(line)
one roll : 4
one roll : 5
one d20  : 12

  1  ############   125   20.8%
  2  ##########     103   17.2%
  3  #########       90   15.0%
  4  #########       92   15.3%
  5  ###########    106   17.7%
  6  ########        84   14.0%
  600 rolls | most common face: 1 (125 times)
This is Ajai's Dice Roll Simulation, from his 99 Projects folder. His original has the right instinct already — a roll_dice() that returns a number, and a main() that loops until you stop — and we keep it, then push the split one step further. Three machines, three jobs, and each one hands something back. roll knows about one die and nothing else; its sides=6 default is what lets the same function roll a d20 on the third line without a word of new code. simulate knows about repetition: it calls roll six hundred times and returns a dict of face → count, the chapter-7 container doing exactly the job it was built for. summarize knows about words, and notice that it prints nothing at all — it returns a list of lines and lets the caller decide. That is the same discipline as the report program: compute in the middle, print at the edge. The one line doing quiet heavy lifting is random.seed(9). It fixes the generator's starting state, so the numbers stay random-looking but come out identical every run — which is how the output above can be printed on a page at all. Two things to try. Delete the seed line and run it three times: the shape holds, the digits never repeat. Then call simulate(600, sides=20) and watch the bars flatten out, because twenty faces splitting six hundred rolls leaves each one thinner.
THREE CLOSURES, ONE SURPRISE
[lambda: i for i in range(3)] — a list comprehension, next chapter's tool; read it as 'build three functions, one per loop turn' — makes three functions that all return 2. A closure captures the variable, not its value, and by the time you call them the loop has long since left i parked at its final value. Every closure is reading the same cell, late. It is the most-reported "bug that is not a bug" in Python.
Wait — the closure's backpack is astonishingly light. The heap cell holding count weighs about 40 bytes; give the same one counter to a class instance and it drags a per-instance __dict__ of roughly 300 bytes more. For private, persistent state, a function-with-a-backpack is the leanest tool in the language — around 7× lighter than the class you would reach for by reflex.
Wait — "functions are values" includes Python's own. You can hand sort a built-in machine directly: words.sort(key=len) passes len itself — no parentheses — and sort runs it once per item. Check it: callable(len) is True, callable(200) is False. First-class is not a privilege reserved for functions you wrote.
Trace3 step the machine — idea · code · memory move together
A function with a backpack — the frame pops, the cell lives on
An inner function that keeps using an enclosing variable makes Python box that variable in a heap cell from the moment the outer function is compiled — the capture is decided ahead of time (co_cellvars), not rescued at the last instant. So when the outer frame pops it takes only the stack reference; the cell — private, persistent, with no global and no class — lives on. Press Next and watch the frame's reference drop while the cell keeps climbing.
closure · cellnonlocalcell · 40 B
The backpack — frame on the stack, cell on the heapclosurecell
plays() fired  ×0 STACK HEAP THE MACHINE make_counter() frame · on the stack holds: count, bump writes 0 heap cell · 40 B count = 0 closure grip bump() inner function closure just built ② a SECOND make_counter() — its own cell, proof of independence make_counter() #2 a fresh call its own cell count = 0 plays2 independent
In plain words
Under the hood
SETALU · the operation
Program · counter.py
in scope · type
Memory · heap cells
registers — the running state
heap · cells the boxed, captured state
what's happening — frame vs cell
Frame
Cell
beat 1 / 1
PROGRAMS run them line by line — the same ideas, written the many ways Python offers
Chapter 09, in working code

A function is a machine you give a name: feed it arguments, it runs its own private lines, and hands one value back. This chapter watches the line-marker step into those machines and out again — and shows you the moment a caller's variables grey out because a function can never see them.

SAY IT BACKthe chapter in five breaths
  1. def is two quiet acts — build the machine, bind the name — and the body sleeps, compiled and shelved, until a pair of parentheses presses GO.
  2. Arguments fill slots by position, by name, or by a default — and a default is built once, when def runs, which is why a mutable one quietly remembers across calls.
  3. Exactly one object crosses backreturn ends the call in the same breath, a comma packs a tuple, and a function that never speaks still answers None.
  4. Every call pushes a private frame of locals and every return pops one — a traceback is that stack, photographed at the moment it broke.
  5. A name is a question asked of four rooms in one fixed orderLEGB, wrapped around where the def was written — and the machine itself is a value: hand it over bare, no parentheses.
You already owned the pieces: chapter 3 taught names binding to objects — def is one more binding; chapter 6 built the tuple that return a, b packs and *args gathers; chapter 7 opened the dict that **kwargs arrives in; chapter 8 stacked the plates that turned out to be the call stack itself. This chapter only wired them into machines with names.
Naming a machine
def builds the machine, return hands a value back, and the caller catches it.
Passing arguments your way
Name them out of order, or gather however many arrive.
Where names live (scope)
Every call gets its own private drawer of names, invisible to everyone else.
Functions as values
A function is a thing you can return, store, and hand to other functions.
A machine that calls itself
Recursion stacks a fresh frame each time — then unwinds them one by one.
end of chapter 09 · six sections
iolinked
designed & developed by Ajai Raj
Python, from the metal up · a product of iolinked