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.
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.
# 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.
-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.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:
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 KBThere 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.
0x5000 + i×8, one multiply. Follow the gold arrow to the song that address points at, sitting off on the heap.A shallow copy — b = 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:
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.
["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? →
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.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.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.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.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.
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:
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.
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.56 + 8 × capacity, and capacity climbs the ladder 4, 8, 16… each jump a fraction bigger than the last.✗ 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.
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. →
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.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.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.
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.i lives at base + i × 8 — computed, never soughtfigurei falls straight out of base + i × 8. Indexing computes where to jump; it never searches to find it.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.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:
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 countedob_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.)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.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)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 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. →
base + i×8, and a big number is no harder to multiply than a small one.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.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.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.
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:
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 backThe 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.
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.insert(0, x) or pop(0) once per iteration shifts the whole list every time — n operations, each costing up to n shifts, ≈ n² 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.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.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.append-then-reverse beats a parade of insert(0, …).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?✗ 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. →
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.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.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.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.
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 endThe 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.
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.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.
a and b — watch the photocopy roll outinteractiveb 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.[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.
str objects. The photocopier duplicated the shelf of addresses — never the songs themselves.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.'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. →
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.playlist[:3] and playlist[-3:] stay cheap on any list, however vast.= 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.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.
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":
# 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.
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.
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.{("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:
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. →
() 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.("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.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.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.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.