python/volume-1/chapter-06ch 06 / 64 · 22 min

06A row of slots

In Chapter 5 we walked a playlist with a for loop, one item at a time, by position. We never asked where those items physically sit. In this chapter we go and look. I want to take it slowly, because the answer decides everything that follows. Here's the plan. We lay a Python list out on the street of numbered boxes from Chapter 0 and read its real shape in RAM, one slot at a time. The whole way through, we keep asking the one question that matters: what is really in a slot? Get that right and every cost in the chapter falls straight out of it. That's why append rides on spare seats and stays almost free, why indexing is a single multiplication instead of a search, why insert(0, …) makes the entire middle pay rent, what a slice photocopies, and how a tuple seals the track shut yet still opens back up on unpacking. By the end you'll read a list the way the interpreter does, and price any operation before you run it. That's the exact skill you'll need next, when these slots give way to the hashed rooms of dict and set.

A list is a row of numbered slots Spare seats: why append is almost free Indexing is a multiplication, not a search The middle charges rent
scroll to begin

01A list is a row of numbered slots

Let's pick up exactly where Chapter 5 left off. That for loop walked the playlist item by item, by position. But it never asked the obvious thing underneath it. Where do those items physically sit? And why is jumping straight to the thousandth one instant, while wedging a new one into the middle is slow? Both answers hide in one place: the list's actual shape in memory. So let's lay it out on the street from Chapter 0. You learned there that the whole of memory is one long street of numbered boxes. A Python list is the first grown-up structure we build straight on top of that street, and CPython builds it as a dynamic array. Array because it rents a run of boxes that sit side by side, no gaps. Call each one a slot. Dynamic because that run can grow when you need more room — the sleight of hand the next section catches in the act. On a 64-bit machine every slot is exactly the same width: 8 bytes. Get that layout right and every cost in this chapter's table falls straight out of it.

Now watch the part that trips up almost everyone. What sits in a slot is not the song. It isn't even the song's title. The audio behind "Blinding Lights" is millions of bytes, and the title "Blinding Lights" is a str object of its own — fifteen bytes of text plus a header. Neither of those fits in 8 bytes. So what the slot actually holds is a reference: the bare address of where that str lives, somewhere else entirely on the heap. The list is a rack of signposts, not a shelf of songs.

playlist.pypython
# three str objects; the list holds three addresses to them
playlist = ["Blinding Lights", "Titanium", "Levels"]
print(id(playlist[1]))        # 1278453922160  — the address of the "Titanium" str
print(hex(id(playlist[1])))   # 0x129a9cab970  (a fresh number every run)

In CPython, id(x) hands you exactly that address, and slot #1 is holding precisely that number — 8 bytes wide, whatever it points at. That unlocks a small marvel. Because every slot is just an 8-byte address, the slots are perfectly interchangeable. One list can hold a str, an int, and another whole list at once without breaking a sweat. The container is rigidly uniform even though the contents are wildly different sizes. An address to a 3-byte number and an address to a 3-megabyte object are both, in the slot, just 8 bytes. That is the list at reading altitude — a rack of signposts to whole songs, each one hours of audio away on the heap. Drop one altitude, down into a single song, and the picture inverts: the numbers that actually are the music are not signposts to anything.

THE ONE IDEA TO CARRY FORWARD
A list does not contain your objects. It contains a tidy, evenly-spaced row of 8-byte addresses — one signpost per entry — and the objects themselves live scattered across the heap. Everything else about lists in this chapter falls out of that single fact.
THE SAME OBJECT, ONE ALTITUDE DOWN
Take "Blinding Lights". Its duration — 200 s — is human metadata; its samples are the actual bytes. Mono 16-bit audio is a waveform: a long run of signed 16-bit integers (-32768..32767, 0 = the speaker cone at rest), one per instant, 44,100 a second — so 200 s is about 8.8 million of them. Hold them in a list and you are back to 8.8M signposts, each pointing at a separately-boxed int. Hold them in an array('h') and the signposts vanish: the raw 2-byte samples sit packed shoulder to shoulder, the sample in the slot, no heap hop per number. Same track, one altitude below the playlist.
The reference model — signposts here, songs elsewherefigure
the name holds one address — where the list object lives playlist list object · 3 used · 1 spare · every slot 8 bytes spare 0x5000 0x5008 0x5010 0x5018 str object 'Blinding Lights' @ 0x7A40 str object 'Titanium' @ 0x82C8 str object 'Levels' @ 0x9B10 the slot block is one solid run; the songs it points at are scattered wherever there was room
Fig 01 — The list is a contiguous block of 8-byte references sitting shoulder to shoulder (with a spare seat at the end); the title strings they point at are strewn across the heap at unrelated addresses.
↺ the thing people get backwards
Most people picture a list with your values stuffed inside it. It's really a list of 8-byte directions to them. A list of a thousand enormous strings is itself tiny — a few kilobytes of signposts — because it never holds the strings, only where they are. And when a list is copied or grows, the songs never budge; only the directions get recopied.

You can watch the container stay tiny no matter how heavy its contents get. sys.getsizeof measures only the list's own header and slot block, never the objects the slots point at. So a list of three hundred-kilobyte strings weighs about the same as a list of three one-letter strings:

weigh.pypython
import sys
print(sys.getsizeof([]))                 # 56   — the bare list header, zero slots
print(sys.getsizeof(playlist))           # 88   = 56 + 4*8  (three songs + one spare seat)

huge = ["z"*100000, "y"*100000, "x"*100000]
print(sys.getsizeof(huge))               # 80   — still under 100 bytes...
print(sys.getsizeof("z"*100000))         # 100041  — ...though each string is 100 KB

There it is in numbers: 88 bytes for the whole three-song playlist, but 100 041 bytes for a single one of the strings it could point at. The list is the cheapest part of the whole arrangement — 56 bytes of header plus 8 bytes per slot, and nothing more. (That 56 + 4×8, not 3×8, is your first glimpse of the spare seat the next section is entirely about.) You may have caught huge weighing 80, not 88, though it also holds three songs. This is a labelled shortcut worth one line. A list literal built from constant strings (like playlist) is grown with a spare seat baked in, so 56 + 4×8 = 88. But a literal whose items must first be computed — like "z"*100000 — is built exact-fit, three slots and no spare, so 56 + 3×8 = 80. Same length, different birth. The spare-seat story picks up in the very next section.

Drag the index — the slot's address is computed, the song is one hop awayinteractive
base address 0x5000 · stride 8 bytes · the slot is never searched for list object — one solid run of 8-byte slots #0 #1 #2 spare 0x5000 0x5008 0x5010 0x5018 str object 'Blinding Lights' @ 0x7A40 str object 'Titanium' @ 0x82C8 str object 'Levels' @ 0x9B10 0x5000 + 1×8 = 0x5008 → the address of 'Titanium'
slot #1 → 0x5008
Slide the index. The highlighted slot is never hunted for — its address falls straight out of 0x5000 + i×8, one multiply. Follow the gold arrow to the song that address points at, sitting off on the heap.
Fig 02 — Because the slots are contiguous and uniformly 8 bytes, "give me item i" is arithmetic, not searching — the same even-stride trick you met for RAM, now holding addresses instead of raw lengths.
Wait — if the slot only holds an address, and copying a list just recopies those addresses, doesn't that mean two lists could end up pointing at the very same song object? Yes — and that's not a bug, it's the whole design.

A shallow copyb = a[:] — builds a fresh slot block and fills it with the same addresses. New rack of signposts, identical signs. So a and b are different list objects, yet a[0] and b[0] are the exact same string, sharing one identity on the heap. The instant two racks point at one song, a sharp question falls out. If you later throw a away, is that shared string safe, or freed out from under b? Python answers it with the tally you built in chapter 3. Every object carries a small reference count, a running number of how many signposts currently point at it. Copying a signpost ticks the count up (1 → 2), and dropping one ticks it down. The moment it reaches 0, nothing can reach the object and it is freed on the spot. That refs badge climbing in the trace below is exactly that count — who-may-free-this, turned into a number:

copy.pypython
a = ["Blinding Lights", "Titanium", "Levels"]
b = a[:]                 # a brand-new list — its own slot block
print(a is b)            # False  — two different containers
print(a[0] is b[0])      # True   — but the SAME song; only the address was copied

✗ The myth

A list is a box, and putting a song "in" a list stores the song inside that box — so copying the list duplicates the songs.

✓ The reality

A list stores 8-byte addresses, not songs. Copying the list duplicates the addresses; every song still exists exactly once, now pointed at from two places. Cheap to copy, and shared by default.

WHY ONE CONTAINER HOLDS ANY MIX
Since a slot is always a plain 8-byte address, the list never has to care what kind of thing it points at. ["Blinding Lights", 200, ["Titanium", 245]] — a str, an int, and a nested list — packs into the same uniform slots without complaint. The rigid, boring uniformity of the container is exactly what buys its total freedom about contents.
The deeper cut — "the slot holds the object" is a useful shorthand

Two small simplifications we leaned on, now paid back in full. First, we said id(x) "is the address." That is true in CPython specifically. The language only promises id is a unique, unchanging integer for the object's lifetime; CPython just implements that promise by handing back the memory address. A different interpreter (PyPy, say) may give you a fabricated id that isn't a real address at all. The mental model — slot holds a reference to an object elsewhere — stays exactly right. Only the literal "id equals address" is CPython's private habit.

Second, we drew the header and the slots as one gold box. In the real PyListObject they're two allocations: a small fixed header (length, capacity, and a pointer) and a separate contiguous block of slots that the header points to. That's why sys.getsizeof([]) is 56 bytes of header with no slots yet, and why the slot block can be swapped for a bigger one without the list's own identity changing — the seed of how a list grows. One thing here is dead literal, not simplified: those slots really are contiguous and really are 8 bytes each on a 64-bit build, which is the whole reason a[i] is instant.

Those slots have to be contiguous — touching, no gaps. So what happens the moment you append a fourth song and the box next door is already rented to someone else? →

Wait — a list with 200 in it and another list with 200 in it don't store two 200s. Because the ints -5 through 256 are cached as permanent singletons, both slots hold the same address — a[0] is b[0] comes back True. The slot copies an address; the little int already exists exactly once.
THE SLOT IS THE ID
The 8-byte number sitting in slot i is literally id(playlist[i]) — a real memory address like 2,467,299,256,176 (a fresh number every run). Whatever it points at — a 3-byte int or a 3-megabyte string — the address itself is always the same 8 bytes wide.
A THOUSAND GIANTS, EIGHT KILOBYTES
A list of a thousand 100-KB strings weighs about 8 KB itself — sys.getsizeof(list(range(1000))) is 8,056. It never holds the songs, only a signpost to each, so the container stays tiny no matter how heavy its contents grow.
TWO HOPS TO A SONG
Reading playlist[1] is a double lookup: first read the address parked in slot 1, then hop to wherever that str actually lives on the heap. Two fetches, not one — but both are constant-time, so the whole thing is still instant.
Trace3 step the machine — idea · code · memory move together
The signpost rack — how a slot ends up holding 0x82C8, not 'Titanium'
A list slot never holds the song — it holds an 8-byte address that points at an object living elsewhere on the heap. So a shallow copy duplicates the signposts while the songs stay put and shared. Press Next and watch a slot fill, then a copy share.
3 slots8-byte pointersshallow copy
The idea — slots hold addresses, songs live on the heaplist · 3 × 8Binit
str1'Blinding Lights'@0x7A40str1'Titanium'@0x82C8str1'Levels'@0x9B10id(playlist)0x1F00slot 0slot 1slot 2id(copy)copy[0]copy[1]copy[2]
the slot copies the address; the song exists once and is shared.
In plain words
Under the hood
BUILDALU · the operation
Program · signposts.py
variables · type
Memory · registers + heap
registers — slots, rack ids, is-result
heap · str objects three songs — each lives once, shared by address
what's happening
beat 1 / 1

02Spare seats: why append is almost free

A list lives as one unbroken run of slots — contiguous, 8 bytes each, wall to wall. That layout is what makes it quick. It also comes with a catch you can feel under your thumb: a run of boxes can't stretch. The boxes on either side already belong to something else. So here is the puzzle. append makes the list exactly one slot longer, yet it costs the same whether the list holds three songs or three million. How can growing a block that can't grow be nearly free?

CPython cheats, and it does it beautifully. When a list needs to grow, it doesn't rent exactly the room it asked for. It rents more. That surplus is called overallocation: a handful of empty slots parked at the end of the block, already paid for, waiting. They are spare seats. An append almost never builds anything. It drops one reference into the next spare seat and bumps the length by one. One write, one increment, done. It takes exactly as long on seat three as on seat three-million: constant time.

THE ONE IDEA TO CARRY FORWARD
A growing list quietly keeps spare capacity it hasn't handed out yet. Most appends cost nothing but a single write into a waiting slot. Once in a while the spares run out and the whole block has to move — but that move is rare enough that, averaged over all the cheap appends, the cost per append stays flat. That average has a name: amortized O(1).

You can watch the spare seats appear. sys.getsizeof reports the container's own bytes: a fixed 56-byte header (the list's length, its capacity, and a pointer to where its slots live) plus the slots themselves, 8 bytes apiece, one per seat. It never counts the songs the slots point at, since those are separate objects elsewhere in memory. So the number it hands back is really 56 + 8 × (seats). Append one title at a time and you can read the hidden capacity straight off it:

spare_seats.pypython
import sys

playlist = []
print(sys.getsizeof(playlist))      # 56   header only, zero slots

for title in ["Blinding Lights", "Titanium", "Levels",
              "Wake Me Up", "One More Time"]:
    playlist.append(title)
    print(len(playlist), sys.getsizeof(playlist))

# 1  88     first append rents 4 seats: 56 + 4×8
# 2  88     spare seat used — no growth
# 3  88
# 4  88     last spare taken; block now full
# 5 120     spares gone — regrows to 8 seats: 56 + 8×8

(Measured on the CPython 3.12 running as I write this, 64-bit. The exact byte counts drift between versions and machines; the staircase — long flat runs broken by sudden jumps — never does.) Look at what the first append did. You asked for room for one song, and CPython quietly rented four seats. Songs two, three and four then cost nothing: they slid into seats already paid for, so getsizeof doesn't budge. Song five is the interesting one. The four seats are full, there is no spare, and the block cannot extend into its neighbours. This is moving day.

On moving day CPython finds a fresh, bigger block somewhere else in memory, copies the existing references across, drops the new song in, and frees the old block. That copy is genuinely O(n), since every reference has to be carried over. If it happened on every append, building a list would be O(n²) and painfully slow. The trick is that it doesn't. Because each new block is sized a fraction bigger than the last, moving days grow rarer and rarer as the list lengthens. Spread the cost of one move across the long run of free appends between moves, and it melts to a constant. That spreading is exactly what amortized means: total cost ÷ number of operations. The list changes apartments a few times in its life and coasts on spare rooms in between. Averaged over every append, moving day barely registers.

Moving day: rent a bigger block, copy the arrows, free the old onefigure
before — block @ 0x5000 · capacity 4 · every seat taken Blinding… Titanium Levels Wake Me… @ 0x5000 — freed after the move .append("One More Time") no spare seat left! copy 4 references — the songs themselves never move after — new block @ 0x9000 · capacity 8 Blinding… Titanium Levels Wake Me… One More…new spare spare spare @ 0x9000 one move buys many free appends → append is O(1) amortized
Fig 1 — Growing means renting a bigger block elsewhere, copying the references, and freeing the old block. It is rare — most appends just land in a spare seat that was already waiting.
Wait — if the whole block moves to a new address, do my songs move too? No — and this is the quiet reason the copy is only O(n) and not something far worse. The slots don't hold songs; they hold references, 8-byte arrows pointing at song objects that sit elsewhere. Moving day copies the arrows, not the things they point at. Run id() on each title before and after a regrow and every number comes back identical — the objects never stirred. Only the little board the arrows are pinned to got swapped for a bigger one.
Slide the list's length — watch the spare seats fill, then a rare moving dayinteractive
capacity: 8 seats sys.getsizeof → 120 B free ride — the new song dropped into a spare seat 56 + 8×8 = 120 B · regrows so far: 2
6 songs → 8 seats
Slide from 0 upward. Green seats hold a song; dashed seats are spares waiting. Most steps are a free ride into a spare; watch for the rare moving day at length 1, 5 and 9, where the block jumps to a bigger capacity and the byte count leaps. Long flat runs, sudden jumps — that is the staircase.
Fig 2 — The block only grows on a moving day; in between, appends quietly fill the seats it already paid for. Bytes = 56 + 8 × capacity, and capacity climbs the ladder 4, 8, 16… each jump a fraction bigger than the last.
↻ reframe
Append isn't cheap because copying is fast — copying a million references is not fast. It's cheap because copying is rare. Stop picturing a speedy move and picture a move that almost never happens: pay for a big block once, then collect dozens of free appends before you pay again. The speed lives in the spacing of the moves, not the moves themselves.

✗ The myth

To stay contiguous, append must grab one more slot and shove the whole list into a fresh block every single time — so filling a big list re-copies everything over and over, O(n²).

✓ The reality

Growth is proportional, not by-one. Each block is a constant fraction bigger than the last, so regrows thin out geometrically. Any one song is copied only a bounded number of times over the list's whole life — n appends cost O(n) in total, O(1) each.

THE CLOCK AGREES
Appending 100 thousand, then 1 million, then 10 million items on this machine, the cost per append held at ~40–50 nanoseconds even as the list grew a hundredfold. Total time tracked the count, not the count squared — the ten-million run finished in about half a second. If append secretly re-copied the whole list each time, that run would have taken hours.
The deeper cut — the exact growth rule, and a literal's hidden spare

When CPython's list_resize needs more room it asks for roughly newsize + (newsize >> 3) + 6 slots — about 12.5% headroom plus a small constant — then rounds down to a multiple of four. Walk it forward and the capacity climbs a fixed ladder: 0, 4, 8, 16, 24, 32, 40, 52, 64, 76… That proportional step is the whole proof of amortized O(1). Because every block is a constant fraction bigger than the one before, each song is carried across only a bounded number of regrows in its lifetime, so n appends do O(n) work in total. Two footnotes, both real on this box. A three-song literal ["Blinding Lights", "Titanium", "Levels"] already measures 88 bytes — 56 + 4×8, a spare seat baked in before you append anything — while an exact-fit copy playlist[:] trims to 80. And that 56-byte header is itself 40 bytes of C struct plus 16 bytes of garbage-collector bookkeeping. All of it is a CPython implementation detail. The language guarantees only that append is amortized O(1), never these particular numbers.

Those slots sit at a fixed stride from one base address — 8 bytes apart, no gaps, no surprises. Which means to reach playlist[764213] the machine needn't walk past the 764,212 in front of it. Indexing, it turns out, isn't a search at all — it's a single multiplication. →

THE FIRST APPEND RENTS FOUR
Add one song to an empty list and getsizeof jumps 56 → 88 — a leap of 32 bytes, or four 8-byte seats, when you only asked for one. Songs two, three and four then cost nothing; they slide into seats already paid for.
Wait — the block doesn't grow by one, it grows by a fraction. Capacity climbs a fixed ladder — 0, 4, 8, 16, 24, 32, 40, 52, 64, 76…, roughly newsize + (newsize>>3) + 6, about 12.5% headroom each time. Because every block is proportionally bigger than the last, any one song is recopied only a bounded number of times in the list's whole life.
COUNT THE MOVING DAYS
Fill a list to nine songs and it changes apartments just three times — at length 1, 5 and 9. The other six appends are free rides into waiting seats. Six frees for three moves, and the ratio only improves as the list grows: that thinning-out is amortized O(1), in one picture.
Trace3 step the machine — idea · code · memory move together
The capacity staircase — getsizeof jumps 56 → 88 → 120
A growing list keeps spare seats in hand, so most appends are a single free write. Only when the spares run out is it moving day — rent a bigger block and copy everything across. Averaged over all the appends, that rare copy is cheap: amortized O(1).
append · amortized O(1)grow 0→4→88 B / seat
The seat block — used glow, spares waitlist · seatsinit
capacity 0moving day
56 88 120 0 1 2 3 4 5 bytes items →
In plain words
Under the hood
INITALU · the operation
Program · playlist.py
variables · type
Memory · the seat block
registers — the running state
list buffer no block yet
what's happening
beat 1 / 1

03Indexing is a multiplication, not a search

You just watched a Python list as it truly sits in memory: one contiguous block of equal-sized slots, each holding an 8-byte reference to the object it stores. Hold onto those two words, contiguous and equal-sized, because together they hand you something that feels close to cheating. Since every slot is exactly 8 bytes wide and they sit shoulder to shoulder, the location of slot i is never something the machine has to look for. It's something it can calculate in one breath: the address of slot i is simply base + i × 8. Hold that 8. It is the list's stride, the width of one slot. In a moment the very same formula, at stride 2, will walk a song's raw samples instead of its signposts.

Take playlist[3]. Say the block begins at address 0x5000. Slot 3 starts 3 × 8 = 24 bytes past that base, at 0x5000 + 24 = 0x5018. One multiply, one add, and the reference is in your hand. The machine never touches slots 0, 1, and 2 on the way. It doesn't walk, doesn't count, doesn't scan. And here is the part that should feel faintly unfair: fetching playlist[0] and playlist[999999] cost exactly the same. The index is just a number you drop into the formula, and a big number is no more work to multiply than a small one. That is the whole meaning of O(1): cost independent of n. The name for this superpower is random access, and it is the entire payoff for keeping the block contiguous.

THE ONE IDEA TO CARRY FORWARD
Indexing a list is arithmetic, not a hunt. playlist[i] computes an address (base + i × 8) and jumps straight to it — the same three tiny steps whether i is 3 or 3 million. "The i-th item" is a multiplication the hardware barely notices, not a search that gets slower the deeper you reach.
Slot i lives at base + i × 8 — computed, never soughtfigure
address(i) = base + i × 8 address(3) = 0x5000 + 24 = 0x5018 — land straight here Blindin… Titanium Levels Wake Me… Uptown… Get Lu… One Mo… Clarity 0x5000 0x5008 0x5010 0x5018 0x5020 0x5028 0x5030 0x5038 same three machine steps whether i = 0 or i = 999,999 · the block never moves, the index just picks a slot
Fig 07 — Every slot is 8 bytes and they sit contiguous, so the address of slot i falls straight out of base + i × 8. Indexing computes where to jump; it never searches to find it.
Slide the index — watch the address get computed, not foundinteractive
each slot holds an 8-byte reference · base 0x5000 · stride 8 bytes #0 #1 #2 #3 #4 #5 #6 #7 Blindin… Titanium Levels Wake Me… Uptown… Get Lu… One Mo… Clarity 0x5000 0x5008 0x5010 0x5018 0x5020 0x5028 0x5030 0x5038 address = 0x5000 + 3 × 8 = 0x5018 cost: 1 multiply, 1 add, 1 jump — O(1), any i a scan would read 4 slots
playlist[3] → 'Wake Me Up'
Drag i from 0 to 7. The gold slot is never found — its address drops straight out of 0x5000 + i × 8. Notice the green cost never moves while the red "if it searched" number climbs: that gap is the difference between random access and rummaging.
Fig 07b — The jump lands on slot i directly, for every i, at fixed cost. A linear scan (red) would pay i + 1 reads to reach the same slot; the array pays one, always.

Python bears this out with a straight face. Ask for any slot, ask for the last one, ask for the length. Each answer is arithmetic, and none of them walks the block:

indexing.pypython
playlist = ["Blinding Lights", "Titanium", "Levels",
            "Wake Me Up", "Uptown Funk", "Get Lucky",
            "One More Time", "Clarity"]

print(playlist[3])      # Wake Me Up   — base + 3×8, one jump, zero scanning
print(playlist[-1])     # Clarity      — base + (len-1)×8, still just arithmetic
print(len(playlist))    # 8            — read from the header, never counted
len() is O(1) too
A list carries its own length in its header — a single stored count (ob_size) that it bumps on every append and pop. len(playlist) just reads that number back; it never walks the slots. I timed len() on a 3-item list and a 20-million-item list — identical. (That header is also why an empty list already weighs 56 bytes before you've stored a single thing, and why each slot you add costs exactly 8 more: getsizeof ticks 56 → 64 → 72.)
STRIDE 2, AND THE ONE WALK YOU CANNOT SKIP
Aim base + i × stride at a song's own bytes and the same arithmetic holds — only the numbers move. An array('h') of samples has stride 2, not 8, and since the sample is the slot, track[i] is one jump with no second hop to the heap. But random access finds a slot you can name — it cannot find the loudest one. The peak (the largest sample) has no address you can compute, so a for loop must read every one of the ~8.8 million samples, holding a running max. Indexing is O(1); the peak walk is O(n), and no stride trick shortens it.
peak.pypython
from array import array
# one song's bytes — signed 16-bit samples, 2 bytes each, packed
samples = array('h', [0, 12000, 24000, 18000, -6000, -22000, -14000, 3000])

peak = samples[0]
for s in samples:          # an O(n) walk — read every sample once
    if s > peak:
        peak = s
print(peak)                # 24000 — the loudest sample (index 2)
Wait — if playlist[i] is just base + i × 8, what stops playlist[99] on an 8-item list from happily computing 0x5000 + 792 and reading whatever garbage lives there? Nothing in the arithmetic stops it — that address is perfectly real. What stops it is a guard rail Python bolts on top: before it computes the address, it checks i against the stored count and raises IndexError: list index out of range if you're past the edge. A raw C array skips that check and will cheerfully hand you the garbage — the source of a thousand security holes. Python trades a hair of speed for never letting you read a slot that isn't yours.
↻ the index is directions, not a question
You're used to "find item 3" sounding like a request the computer has to go and satisfy by looking. Flip it: the index isn't a question, it's a set of directions. "3" already is the answer — multiply by the stride, add the base, and you're standing on the doorstep. The list doesn't search its slots any more than you search a street when someone hands you the house number.

✗ The myth

To reach item i, the list starts at the front and steps forward i times — so items deeper in the list cost more to fetch, and a huge list is slow to index.

✓ The reality

It never steps. It computes base + i × 8 and lands in one hop — item 3 and item 3-million cost the same. I peeked at the raw pointer array of a real list: the slot at base + i×8 holds exactly id(playlist[i]), for every i. The formula isn't a metaphor for what happens; it is literally where the reference sits.

The deeper cut — "multiplication" is generous, and "one jump" is really two

Two honest refinements to everything above. First, the number 8. A reference is 8 bytes only because you're on a 64-bit CPython build (sys.maxsize > 2**32 is True on this machine). On a 32-bit build the very same slot is 4 bytes and the stride is 4. The magic was never the number 8. It's that the stride is fixed, whatever it is, so base + i × stride always lands true. Label it a simplification and keep walking; the idea survives the number.

Second, "one multiply, one add, one jump" both oversells and undersells at once. It oversells because a list slot holds a reference, not the string itself. So reaching 'Wake Me Up' is really two fetches: read the pointer sitting at base + i×8, then follow it to wherever the text actually lives elsewhere in RAM (that "one extra hop" from chapter 0). Both fetches are constant-time, so it's still flatly O(1) — just two constant steps, not one. And it undersells the arithmetic, because a compiler almost never emits a real multiply for ×8. Times-eight is a left shift by three (i << 3), and x86-64 folds even that into a single addressing mode: one instruction like mov rax, [rbx + rcx*8] computes base + i×8 and reads it, essentially for free. "Multiplication" is the honest mental model; down at the metal the CPU barely breaks a sweat.

Jumping to a slot is free, at any depth. But now insert a song between slots 2 and 3 — every reference above it has to physically shuffle over to make room. The middle charges rent. →

SAME NANOSECONDS, ANY DEPTH
Indexing truly doesn't care how deep you reach: fetching item 15,000,000 from a 20-million-item list clocked ~34 ns; fetching item 5 from a ten-item list, ~33 ns. The index is just a number dropped into base + i×8, and a big number is no harder to multiply than a small one.
↺ reframe
Random access is the quiet reason binary search works on a list at all. Binary search is the trick for finding a value in a sorted list without scanning it: look at the middle element, throw away the whole half it can't be in, and repeat — so a million items collapse to about twenty checks instead of a million. But that halving only pays off if you can jump straight to the middle element each time — and base + i×8 lets you, in one hop, for any i. On a linked chain, where reaching the middle means walking there, bisection would be pointless: you'd spend O(n) crawling to the very element that was meant to save you the crawl. The instant index isn't just convenient; it's what makes a whole family of fast algorithms possible.
Wait — playlist[i] = x is O(1), but playlist.insert(i, x) is O(n). Same slot, opposite cost? Overwriting drops a new address into one slot and leaves the length untouched, so nothing else moves. Inserting adds a slot, so every reference from i onward must shuffle over first. Changing a slot is free; making a new one charges rent.
Trace3 step the machine — idea · code · memory move together
playlist[3] is one address — not a walk
Fetching playlist[3] isn't a stroll down the list. Watch it compute one address — base + 3×8 — jump straight there, read the 8-byte pointer, and hop once to the song. Two loads, still O(1). Then meet the opposite: "x in list" really does walk every slot.
8 slots · 8-byte refsindex O(1)search O(n)
playlist[3] — compute the address, then two loadswant
addr = 0x5000 + 3 × 8 = 0x????
slot holds a pointer
0x8F20 'Wake Me Up' str object · scattered on the heap
playlist[3] — jump straight to one slotO(1)
"Clarity" in playlist — read every slotO(n)
In plain words
Under the hood
ADDRALU · the operation
Program · indexing.py
Memory · slots, pointers, heap
what's happening
beat 1 / 1

04The middle charges rent

Back in chapter 0 you met the one trick that makes a list fast: its slots sit shoulder to shoulder in memory, one even-strided street of references, so "give me song #3" is a single multiply — base + 3 × 8 — not a search. That contiguity is a gift. But every gift has a price tag, and here is the invoice. Because there is no gap between slot 3 and slot 4, you cannot simply wedge a new song between them. To open a slot at position i, every reference from i to the end must first shuffle one slot to the right to make room. And the shuffle can't run in any order. It has to go top-down: copy the last reference into the empty slot beyond it first, then the second-to-last, and so on inward. Copying bottom-up would overwrite the next reference before it had been moved, smearing one song over its neighbour. That disciplined right-to-left slide is n − i moves before the new song even lands.

So the cost of an edit depends entirely on where you strike. Add a song to the end and nothing below it exists to move — zero shifts, O(1), practically free. Wedge one in at the front and the entire list heaves over by one — O(n). Deleting is the same story played backwards: pull a song out of the middle and the whole tail slides left to close the gap it left behind. The ends are quiet doorways. The middle charges rent, and the rent is every neighbour downstream.

THE ONE IDEA TO CARRY FORWARD
A list's cost is never really in the operation you name — it's in how many neighbours that operation forces to move. Touch an end, pay nothing. Touch the middle, pay for everything after it. Contiguity makes lookups instant and rearrangement expensive, and those are two faces of the exact same coin.

Watch the four edits split cleanly into cheap and costly. Same list, same method names. The only thing that changes is whether you knock at an end or reach into the body:

edits.pypython
playlist.append("One Dance")     # O(1): drops into a spare slot at the tail
playlist.insert(0, "The Days")    # O(n): every song slides one slot right
playlist.pop()                    # O(1): the last song leaves, nothing moves
playlist.pop(0)                   # O(n): the front leaves, the whole tail slides back
Insert at slot 1 — everyone after it slides rightfigure
playlist.insert(1, "Faded") before Blinding Lights Titanium Uptown Funk Levels One Dance after Blinding Lights Faded Titanium Uptown Funk Levels One Dance 4 of the 5 songs slid one slot right — cost = n − i = 5 − 1 = 4
Fig 1 — The new song (green) drops into slot 1, but only after the four references from slot 1 onward (red) each copy themselves one slot along. The closer to the front you strike, the more of the street has to move.

The pattern is exact: strike at index i in a list of n, and n − i references have to move. Drag the position below and watch the bill change. It runs all the way from "the whole list moves" at the front to the one blessed spot at the end, where the cost drops to zero and insert quietly becomes append.

Pick an insert position — count the shiftsinteractive
cost = n − i = 8 − 3 = 5 → O(n) 5 shifts Blinding L… Titanium Uptown F… Levels One Dance Believer Closer Faded 0 1 2 3 4 5 6 7 each red slot slides one step right to open the gap — that sliding is the whole cost
i = 3
Cost = n − i. Push i to 0 and all eight songs must move; slide it to 8 and none do — that free case at the end is exactly what append is.
Fig 2 — The bill is the count of red slots. It falls by one for every step you move the insertion toward the end, hitting zero only when you land past the last song.
THE O(n²) TRAP
Put a middle-edit inside a loop and the costs multiply. Calling insert(0, x) or pop(0) once per iteration shifts the whole list every time — n operations, each costing up to n shifts, ≈ moves. It is not academic: on the machine this page was written on, building a playlist with insert(0, …) took ~0.12 s at 40,000 songs but ~0.51 s at 80,000 — twice the data, four times the work, the unmistakable signature of O(n²). The same 80,000 done with append finished in about two milliseconds. When you need cheap edits at both ends, a structure built for it exists: collections.deque, chapter 8.
What every list operation really costsfigure
operation what actually happens cost playlist[i]jump to base + i × 8O(1) playlist[i] = xoverwrite one reference in placeO(1) playlist.append(x)drop into a spare slot at the tailO(1)† playlist.pop()lift off the last — nothing shiftsO(1) playlist.insert(i, x)shift n − i references rightO(n) pop(i) · remove(x)shift the tail left to close the gapO(n) len(playlist)read the count kept in the headerO(1) x in playlistwalk the slots one by one, comparingO(n) † amortized — the rare regrow-and-copy averaged across all the cheap appends; the deeper cut explains
Fig 3 — The ends are cheap, the middle pays the shift, and searching by value pays a full walk. Read this table and you can predict the speed of almost any list-heavy code before you run it.
BUILD AT THE TAIL
Nearly every "grow a list" loop should append and, if you truly need the other order, reverse once at the end — an O(n) reverse beats an O(n²) parade of insert(0, …). The tail is the list's free doorway; walk through it whenever you can.
Drag across the operations — watch the reference-moves explode 1 → 1,000,000interactive
one line of Python on a list of a million songs — how many references must the machine physically move? lst.append(x) drop the reference into a spare seat already paid for. BIG-O COST O(1)† AT HUMAN SCALE · 1 MOVE = 1 SECOND 1 second ≈ a single heartbeat REFERENCES THE MACHINE MUST MOVE 1 just this one reference stirs — the other 999,999 sit still. the cliff — append vs insert(0): 1 → 1,000,000 moves, a millionfold jump, for touching the front instead of the back.
lst.append(x)
Drag from lst[i] on the left to lst.insert(0, x) on the right. The four cheap ops each move exactly one reference — done before you blink. Cross into the O(n) ops and the counter detonates: touching the front of a million-item list shuffles all million references, on every single call. Same one line of code — a heartbeat versus a week and a half.
Fig 4 — The static table above, made manipulable. One contiguous block of references predicts every cost: the ends are free, the front is a landslide. That spread — 1 → 1,000,000 reference-moves for the same n — is exactly why append-then-reverse beats a parade of insert(0, …).
Wait — playlist[500000] comes back in about 14 nanoseconds no matter how huge the list, yet "Levels" in playlist can crawl through every slot — on a million-item list that membership test measured around 6 milliseconds, roughly 400,000× slower. Why is finding by position free while finding by value costs a full walk? And could some other structure make "is this song in here?" instant too?
↺ reframe
A list is fast in exactly the place a numbered street is fast, and slow in exactly the place a street is slow. You can teleport to any house number in one hop — that's random access, the instant index. But you cannot squeeze a brand-new house between numbers 3 and 4 without renumbering every house after it. The list didn't get "slow at inserting"; it was never able to bend space. Cheap lookup and costly rearrangement are the same contiguity, seen from two directions.

✗ The myth

A Python list is a linked chain of songs, so inserting in the middle is cheap — just unhook two links and splice the newcomer in.

✓ The reality

There are no links to unhook. A list is a contiguous array of references — that one even-strided street from chapter 0. To open a gap at position i, Python physically memmoves every reference from i onward one slot along. Middle insert is O(n); only the tail is free.

The deeper cut — why append wears a dagger (†), and what "amortized O(1)" really means

Calling append "drops into a spare slot" is a labelled simplification — true almost every time. Here is the once-refined truth. A list quietly holds more room than it shows. Ask Python how many bytes a growing list occupies and you can watch it grab capacity in jumps, not one slot at a time. On this machine an empty list is 56 bytes of bookkeeping and each reference adds 8. The underlying block grows through capacities of 4, 8, 16, 24, 32… slots — geometric growth, each block a healthy fraction bigger than the last.

So most appends are genuinely O(1): there's a spare slot waiting, and the reference just drops in. But every so often the spare slots run out, and that append must allocate a bigger block and copy all n existing references across — a one-off O(n) hiccup. Here's the beautiful part. Because the block grows by a multiple each time (not a fixed +1), those expensive copies get rarer exactly as fast as they get bigger. Add up the cost of a million appends and the grand total is still proportional to a million, so the average cost per append is a constant. That average, with the rare spikes folded in, is what the dagger means: amortized O(1). Not "always cheap," but "cheap enough, on average, that you can treat it as cheap." It's the reason building a list at the tail stays fast while building it at the front melts into O(n²).

You've felt the price of nudging one song. Now ask a list for a whole stretch at once — playlist[1:4] — and something surprising happens: Python doesn't hand you a window onto the originals, it hands you a fresh photocopy of that run of references. A slice is a photocopy of a stretch. →

ONE MEMMOVE, NOT N STEPS
The shift isn't a Python loop stepping reference by reference — it's a single C memmove that slides (n − i) × 8 bytes in one bulk operation. Still O(n) work, but carried out by the hardware in one move-block instruction, not n trips around the interpreter.
Wait — pop() and pop(0) read like twins, yet one is instant and the other crawls. pop() lifts the last song off and nothing behind it moves — O(1). pop(0) pulls the front out and the entire tail slides one slot left to close the gap — O(n). The only difference is which end you knock on.
remove() PAYS TWICE
playlist.remove("Levels") is two linear passes back to back: first an O(n) walk to find "Levels" by value, then an O(n) shift to close the hole it leaves. Deleting by value from the middle of a big list is the most expensive everyday move a list has.
Trace3 step the machine — idea · code · memory move together
The sliding street — how insert(1, "Faded") moves four references before anything lands
To open a gap at index i in a contiguous block, every reference from i to the end must slide one slot right — so an insert costs exactly n − i moves. Press Next and watch four references shuffle top-down before Faded can land.
insert @ 1shifts n − iO(n)
The idea — one contiguous row of slotslist · 8 seatsinit
front i=0 → 5 movescost = n − itail i=5 → 0 · append
In plain words
Under the hood
SETALU · the operation
Program · insert_shift.py
variables · type
Memory · registers + object
registers — the running state
playlist list · references in one contiguous block (cap 8)
what's happening
beat 1 / 1

05A slice is a photocopy of a stretch

You have a shelf of five songs and you want just the two in the middle. You don't lift them off the shelf and leave a gap. You walk to the photocopier and copy that stretch. That is a slice. Write playlist[a:b] and Python builds a brand-new list, then copies the references sitting in slots a, a+1, … right up to — but not including — slot b. It's stop-exclusive, the exact same half-open counting range handed you in chapter 5. Read a as a door you step through and b as a wall you stop at. The wall's own slot is never taken.

Bolt a third number on and you set the stride. playlist[::2] takes every other slot. A negative stride walks the shelf backwards, so playlist[::-1] hands you a reversed copy without disturbing a single original slot.

slicing.pypython
playlist = ["Blinding Lights", "Titanium", "One More Time", "Levels", "Faded"]

print(playlist[1:3])    # ['Titanium', 'One More Time']   — slot 3 excluded
print(playlist[:2])     # ['Blinding Lights', 'Titanium']
print(playlist[::2])    # ['Blinding Lights', 'One More Time', 'Faded']
print(playlist[::-1])   # ['Faded', 'Levels', 'One More Time', 'Titanium', 'Blinding Lights']
print(playlist[-1])     # Faded   — one step back from the end

The price is honest and small. Copying k references costs O(k) time and O(k) fresh memory — proportional to the length of the stretch you asked for, never the length of the shelf. Pull ten references out of a million-item list and you pay for ten, full stop. And the original is never touched: a slice reads, it never writes. It cannot mutate the list it came from, because it never reaches back in. It only ever copies out.

NEGATIVE INDICES — COUNT FROM THE BACK
playlist[-1] is just shorthand for playlist[len(playlist) - 1]: the last song. -2 is the one before it. It is still O(1) — the same one-step address arithmetic as any other index, with a single subtraction bolted on first. Slices speak this dialect too: playlist[-2:] is the last two tracks.
Every slot answers to two namesfigure
from the front 0 1 2 3 4 Blinding L… Titanium One More… Levels Faded from the end -5 -4 -3 -2 -1
Fig 05a — Each slot carries two labels: its offset from the front (0-based) and its distance from the end (-1 is always the last). A slice can be written in either dialect.
↺ reframe
Stop imagining a slice as "grabbing part of the list." Picture a photocopier. The stretch you name slides under the glass, a fresh sheet rolls out, and the original goes back on the shelf untouched. You now hold a separate piece of paper — and separate is the whole point. Scribble on the copy and the shelf never knows.

Words are cheap, so drag the handles. Below, the top shelf is the real playlist and the two sliders are a and b. The gold stretch is what they enclose — inclusive on the left edge, exclusive on the right — and the green shelf underneath is the exact new list Python hands back.

Set a and b — watch the photocopy roll outinteractive
playlist — the original shelf (never changes) 0 1 2 3 4 Blinding L… Titanium One More… Levels Faded playlist[1:3] ↓ the slice — a fresh shelf of copied references 2 references copied → O(k), k = 2
1
3
Try a=1, b=3 for the two middle tracks. Push b past the end — it clamps, no error. Pull a past b and the slice goes empty. Through all of it, the top shelf never so much as flickers.
Fig 05b — The gold bracket is the half-open range [a, b); the green shelf is its photocopy. The cost line counts exactly what got duplicated — k = b − a references, and that is all a slice ever spends.

Now the subtlety that trips everyone. What, exactly, did the photocopier duplicate? Not the songs. A list slot never holds a song. It holds a reference, the address of a string object living elsewhere in memory. So the slice copies the addresses, and the new shelf points at the very same string objects the original points at. Python will tell you to your face: playlist[1] is playlist[1:3][0] comes back True. Same object, two shelves pointing at it. That is what a shallow copy means: new shelf, shared songs.

The copy is of the shelf, not the songsfigure
playlist Blinding… •ref• •ref• Levels Faded 'Titanium'str object 'One More Time'str object playlist[1:3] •ref• •ref• one object, two shelves
Fig 05c — Both the original and the slice hold references; those references land on the same two str objects. The photocopier duplicated the shelf of addresses — never the songs themselves.
Wait — if playlist[:] copies the whole list, yet playlist[:][0] is playlist[0] is True, did it actually copy anything? Yes — it built a genuinely new list object (playlist[:] is playlist is False). What it did not do is clone the elements. New container, same contents: that distinction is the whole ballgame.
SHALLOW MEANS SHARED
With immutable strings, sharing is harmless — nobody can change 'Titanium' out from under you. But slice a list of lists and both copies point at the same inner lists: mutate an inner list through one, and the "copy" changes too. That is chapter 3's whiteboard aliasing wearing a fresh coat. A slice is a shallow copy, never a deep one — grid[:] gives you a new outer shelf whose slots still reach the original inner rows.

✗ The myth

"Slicing makes a safe, independent duplicate — a full copy of everything inside." So people slice a list of lists and assume the nested data is theirs alone.

✓ The reality

Slicing copies exactly one layer: the shelf of references. The objects those references point at are shared. It is a photocopy of the index card, not of the warehouse — perfectly safe until an element is itself mutable.

The deeper cut — the one time a slice does mutate, and why it never crashes

Everything above is about a slice on the right of an = — a value being read out, which only ever copies. Put a slice on the left and the rules flip. x[1:3] = ["A"] is slice assignment, and it reaches back into the original and splices, in place. Run it on [10, 20, 30, 40] and you get [10, 'A', 40] — two slots gone, one arrived, and id(x) unchanged the whole time: same list object, surgically edited. A slice on the right is a photocopy; a slice on the left is a scalpel. Same syntax, opposite direction.

And a smaller mercy worth naming: a read-slice cannot throw for being out of range. playlist[2:99] quietly clamps to the end and playlist[99:] hands back an empty list, where the bare index playlist[99] raises IndexError. Slices forgive; indices don't. Under the hood each new list is its own object, too: sys.getsizeof(playlist[1:3]) reports 72 bytes against the original's 104 — a smaller, separate allocation, sized to exactly what you copied.

So the slice is Python's answer to a nervous question — what if someone mutates the list while I'm using part of it? — and its answer is a safe, cheap, shallow copy you can scribble on freely. But notice the whole worry only exists because a list can be re-cut at all. What if a track were pressed once and sealed, a record that by construction can never change a groove?

Meet the tuple: the sealed track, and how to crack one open. →

Wait — playlist[::-1] hands you the whole list reversed, yet the original never flips. A negative stride walks the shelf backwards and photocopies as it goes, so you get a new list (playlist[::-1] is playlist → False) at O(n) cost. The original sits untouched the whole time — a slice only ever reads.
YOU PAY FOR THE STRETCH, NOT THE SHELF
A slice costs O(k) — proportional to how much you asked for, never how long the list is. Pull ten references out of a million-item list and you pay for ten, full stop. playlist[:3] and playlist[-3:] stay cheap on any list, however vast.
A SLICE CANNOT BITE BACK
A slice on the right of = only ever reads — it copies references out and never reaches back in. That's why you can scribble all over playlist[1:4] and the original shelf never so much as flickers: separate list, separate slots, shared songs.
Trace3 step the machine — idea · code · memory move together
The photocopier — how playlist[1:4] copies three addresses onto the same three songs
A slice builds a brand‑new, exact‑fit list and copies the references in the half‑open range [a, b) — so the copy is a different object whose slots point at the very same heap objects. That is a shallow copy. Press Next and watch the addresses, not the songs, get duplicated.
slice [1:4)k = 3shallow · O(k)
The photocopier — one object, two shelveslist · sliceinit
[0]
Blinding Lights
[1]
Titanium
[2]
One More Time
[3]
Levels
[4]
Faded
wall
[ 1 : 4 ) · copy k = 3
'Titanium'
49 Brc 1
@0x2b0e10
'One More Time'
54 Brc 1
@0x2b0e58
'Levels'
47 Brc 1
@0x2b0ea0
part[0]
part[1]
part[2]
In plain words
Under the hood
SLICEALU · the operation
Program · slice.py
variables · type
Memory · registers + heap
registers — the running state
shared songs str objects on the heap · refs from our two shelves
identity — same object, or just the same address?
beat 1 / 1

06Tuples: the sealed track, and how to crack one open

A tuple is a list that has been welded shut. Same idea underneath: a run of contiguous slots, so you still reach any element in one hop (track[0] is O(1), no matter how long the tuple) and you can still slice out a smaller piece. But the block is sealed at birth. There is no append, no spare capacity waiting in the wings, no doubling-and-copying when it fills up, because it never fills up. Its size is fixed the instant it's born and can never change again.

Giving up the right to grow buys you three concrete things. First, an exact-fit block. A list keeps empty seats around so the next append is cheap, but a tuple knows it will never append, so it pays for not one wasted slot. Second, and deeper than "a leaner header": a list is really two allocations — a small header holding a pointer to a separate slot block sitting elsewhere (the split the §01 deeper cut drew). A tuple collapses that into one single allocation with its element pointers stored inline, right after the header. There is no allocated field to track and no separate buffer to chase. So a tuple isn't just smaller, it is one indirection cheaper to read: track[i] lands inside the block you already hold, where playlist[i] must first hop through the header's pointer to reach the slot block. That missing hop is the concrete reason tuples index marginally faster, not merely weigh less. Third, and here is a need worth feeling before you meet its answer. A pixel is three bytes, (r, g, b), each channel a number 0–255 — exactly chapter 0's row of switches read as a value. Now count how many pixels of each exact colour a photo holds. Keep a list of [colour, count] pairs and, for every one of a million pixels, you must walk that list to find its colour — O(n) per pixel, a catastrophe. To make it instant you'd need to turn the colour itself into an address you can jump straight to, which demands a value that will never change out from under that address. That is hashability, and only a frozen block can promise it: (255, 0, 0) can be a dictionary key; a list [255, 0, 0] never can. (Chapter 7 spends itself cashing this in.)

The numbers are small enough to hold in your hand. On this machine, an empty tuple's header is 40 bytes, and each slot it points at costs 8 more. So the track ("Blinding Lights", "The Weeknd", 200) weighs 40 + 3×8 = 64 bytes. The very same three fields as a list weigh 88: a fatter 56-byte header, plus room for four slots when only three are filled — one seat left empty for a growth that a tuple has promised never to need.

The sealed track vs. the roomy listfigure
track = ("Blinding Lights", "The Weeknd", 200) tuple · 3 sealed slots · no spare capacity 'Blinding Lights' 'The Weeknd' 200 40 + 3×8 = 64 B the same three fields, built as a list list · 3 filled slots · capacity 4 (1 spare) 'Blinding Lights' 'The Weeknd' 200 spare 56 + 4×8 = 88 B exact fit · leaner header · frozen forever → and so it can be a dict key
Fig 06 — Same three values, two homes. The tuple is an exact-fit sealed block; the list carries a fatter header and an empty fourth seat it will never need to fill. Sealing costs you append and buys you smaller, faster, and hashable.

✗ The myth

The parentheses make the tuple, so (4) is a one-item tuple.

✓ The reality

The comma makes the tuple. (4) is just the int 4 wearing grouping parens; you need (4,) or plain 4, for a tuple of one. And track = "Blinding Lights", "The Weeknd", 200 is a perfectly good tuple with no parentheses at all — the parens are only ever punctuation for your eyes.

Building a tuple by dropping loose values side by side is called packing. Tearing it back apart into named slots is unpacking. Unpacking is where tuples earn their keep in everyday code. It's the machinery behind the one-line swap, and, with a *star name, behind grabbing "the first, and everything else":

unpack.pypython
# packing: the commas build the tuple — the parens are optional
track = "Blinding Lights", "The Weeknd", 200
title, artist, seconds = track          # unpacking: three names, three slots
print(artist)                           # The Weeknd

a, b = "Blinding Lights", "Titanium"
a, b = b, a                             # swap: pack the right, unpack the left
print(a)                                # Titanium

playlist = ["Blinding Lights", "Titanium", "Levels", "Bad Guy", "Uptown Funk"]
first, *rest = playlist                 # star scoops the leftovers
print(rest)     # ['Titanium', 'Levels', 'Bad Guy', 'Uptown Funk']

first, *middle, last = playlist
print(middle)   # ['Titanium', 'Levels', 'Bad Guy']

The star is worth slowing down on. Drag it to any of the three names below and watch the plain names pin themselves to a single slot each, while the starred name sweeps up whatever's left over and hands it to you as a brand-new list.

Move the star — the plain names pin, the star scoops the restinteractive
a, *b, c = playlist Blinding Lights Titanium Levels Bad Guy Uptown Funk a *b c ['Titanium', 'Levels', 'Bad Guy'] a new list a = 'Blinding Lights' c = 'Uptown Funk' Two plain names claim one slot each; the single star sweeps up everything they leave behind.
a, *b, c
Slide the star onto a, b, then c. Wherever it lands, the two plain names still take exactly one slot apiece and the star collects the three leftovers into a fresh list — never a tuple, always a list.
Fig 06b — A star name can sit anywhere in the row, but there can be only one — Python has no way to split the leftovers two ways. The scooped-up remainder is always a new list, even though we unpacked a tuple.

Three rules govern the star, and each falls straight out of what it's doing. There can be only one starred name per unpacking. With two stars, Python would have no principled way to decide where one leftover pile ends and the next begins (it's a hard SyntaxError, caught before the line ever runs). It can sit anywhere in the row — front, middle, or back. And it always collects into a fresh list, never a tuple. That last one is a lovely tell: track[1:]slicing a tuple — hands you back a tuple, but first, *rest = track hands you a list. Slicing preserves the type; star-unpacking always builds mutable.

Wait — if a tuple can never change, how does t += ("Levels",) run without an error? Because it isn't changing the tuple. += on a tuple quietly builds a whole new tuple from the two pieces and re-points the name t at it; the original block is never touched — its id is gone, replaced by a different object. Rebinding a name and mutating an object look the same from across the room, but only one of them is allowed here.
↻ reframe
A tuple isn't a stubborn, read-only list — it's a different shape of thought. Square brackets say "a column of the same kind of thing, and there may be more tomorrow" — five songs, ten songs, a growing playlist. Parentheses say "a record: fixed fields that mean different things" — this is a title, then an artist, then a length, and there will always be exactly three. Reach for a list when it's more-of-the-same; reach for a tuple when the positions carry meaning and the count is nailed down.
A HASH IS A COMPUTED ADDRESS
A hash is not a metaphor — it's a deterministic function that folds a value's contents down to one fixed-size integer (a tuple folds together the hashes of its elements). A dictionary uses that integer to compute which bucket to jump straight to — one calculation instead of an O(n) scan of every key. That is exactly why the contents must be frozen: change a key and it would hash to a different bucket next time and be stranded, unfindable. So {("Blinding Lights", "The Weeknd"): 200} is legal and instant, while hash([...]) raises TypeError: unhashable type — a thing that can change cannot promise a stable address. Hold that thought — it's the hinge the entire next chapter swings on.
The deeper cut — "a tuple can never change" is a labelled simplification

Said plainly, "a tuple can never change" is true enough to build your instincts on. Here's the one place we refine it to the exact truth. What a tuple actually seals is the row of references, not the objects those references point at. Each slot is permanently welded to whichever object it was born pointing to; you can never repoint a slot (t[0] = ... is a TypeError). But if a slot points at something mutable — say a list — that inner object is still free to change out from under the tuple:

shallow.pypython
t = ("Blinding Lights", ["The Weeknd"])
t[1].append("feat.")   # the tuple's slot never moved...
print(t)                 # ('Blinding Lights', ['The Weeknd', 'feat.']) — but its list grew
hash(t)                  # TypeError: unhashable type: 'list'

So the honest rule is sharper than "tuples are immutable": a tuple is hashable only if every object inside it is. Fill it with strings and numbers and it's a rock-solid dictionary key. Sneak a list inside and the frozen shell wraps a beating heart, and Python refuses to fingerprint it. Everywhere else in this book we'll happily say "tuples are immutable," now that you know precisely where that seal stops.

The tuple handed us the one thing a key needs — a fingerprint that never lies. But "Blinding Lights" in playlist still trudges slot by slot, O(n), slower with every song you add. Next: the structure that turns that fingerprint into a single jump to the answer, no matter how vast the library grows — the hash table behind dict and set. →

Wait — () is () is True. Python keeps exactly one empty tuple and hands the same object out every time. It safely can: with nothing inside, two empty tuples can never differ, so why allocate a second? Try [] is [] and you get False — an empty list might grow, so each one needs its own block.
SEALED IS SMALLER
The same three fields, two homes: as a tuple ("Blinding Lights", "The Weeknd", 200) weighs 64 bytes (40 + 3×8); as a list, 88 (56 + 4×8). A tuple carries a leaner header and no spare seat — it has promised never to grow, so it never pays for room it won't use.
THE SWAP'S SECRET TEMP
The famous one-liner a, b = b, a needs no temporary variable because the temporary is a tuple you never see: Python packs the right-hand side into (b, a) first, then unpacks it back into the names. The invisible tuple is exactly the scratch space a manual swap would spell out by hand.
Wait — you can write first, *rest = track, but first, *a, *b = track is a hard SyntaxError, refused before the line ever runs. With two stars Python has no principled way to decide where one leftover pile ends and the next begins — so it allows exactly one star per unpacking, sitting anywhere in the row.
Trace3 step the machine — idea · code · memory move together
The hidden tuple — why a, b = b, a swaps with no temp variable in sight
Multiple assignment packs the right-hand side into a throwaway tuple, then unpacks it into the left-hand names. The “missing” temporary is really an invisible tuple — born and freed in one line. Press Next and watch the two arrows trade targets while the string cards never move.
semantic model · tupleCPython 3.12 · SWAP opt0 bytes copied
The swap — two arrows trade targets, a tuple in betweentuple · transientinit
In plain words
Under the hood
BINDALU · the operation
Program · swap.py
variables · type
Memory · registers + objects
registers — names & the snapshotted addresses
heap · objects two str cards that never move + one transient tuple
what's happening
beat 1 / 1
PROGRAMS run them line by line — the same ideas, written the many ways Python offers
Chapter 06, in working code

A list is a row of numbered slots you can read, cut, grow, and reorder. Here are sixteen tiny programs, each one pinning down a single move. Press Next and watch the row change under your hands.

building the row
Make a list, then reach into it by position — and ask whether something's inside.
cutting the row
A slice copies out a run of slots — start, stop, step — and a negative step walks backward.
growing and shrinking
These methods mutate the list in place — they change the row itself and hand back None.
putting it in order
Reorder the row in place — plain, by a key function, or simply flipped.
reading the whole row
Fold a list to a number, rebuild it with a comprehension, nest it, unpack it, and walk it with an index.
end of chapter 06 · six sections
iolinked.com
Written by Ajai Raj