06A row of slots
In Chapter 5 we walked a playlist with a for loop, item by item, by position. We never once asked where those items physically sit. In this chapter we go and look, and 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 thing that matters: what is really in a slot? Get that right and every cost in this chapter falls straight out of it. You'll see why append rides on spare seats and stays almost free, why indexing is a single multiplication instead of a search, and why insert(0, …) makes the entire middle pay rent. You'll see what a slice photocopies, and how a tuple seals the track shut yet still cracks open 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 muscle 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. CPython builds it as a dynamic array. It's an Array because it rents a run of boxes that sit side by side, no gaps. Call each one a slot. It's 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. 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.
[] is a real list, born ready to grow; [200] is a row of one. The brackets are the whole ceremony.list("hello Ajai") walks the string and gives every character its own slot — ten letters in, ten slots out.list(range(5)) turns a lazy recipe into a real row: [0, 1, 2, 3, 4], stored, countable, indexable.[0] * 100 is a hundred slots sharing one int. Safe here, because nobody can ever edit a 0.["Blinding Lights", 200, ["Titanium", 245]] is a str, an int and a whole list, side by side.you type
greet = list("hello Ajai")
print(greet)
print(len(greet))
print(list(range(5)))
first_name = ['a', 'j', 'a', 'i']
last_name = ['r', 'a', 'j']
print(first_name + last_name)
print("".join(first_name))
last_name[2] = 'i'
print(last_name)
print(last_name[0], last_name[-1])you see
['h', 'e', 'l', 'l', 'o', ' ', 'A', 'j', 'a', 'i']
10
[0, 1, 2, 3, 4]
['a', 'j', 'a', 'i', 'r', 'a', 'j']
ajai
['r', 'a', 'i']
r iplaylist[3]on a three-song row raisesIndexError: list index out of range.lenis 3; the last index is 2.- Negative indices run off the edge too. On three songs,
playlist[-4]raises exactly the sameIndexError. list("Titanium")gives you eight one-character slots, not one slot holding the word. To wrap a single string, write["Titanium"].playlist[1] = "Faded"overwrites a slot and leaves the length alone. That is a different act frominsert, which adds one.[0] * 3is harmless because ints are frozen.[[]] * 3is three names for one list — a trap this chapter takes apart before it ends.
# 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. 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. Now 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. It never counts 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. Here's 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, written 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. And 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 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 stays exactly right: a slot holds a reference to an object elsewhere. 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. There's a small fixed header, holding length, capacity, and a pointer. Then there's 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. It's also 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? →
a[0] is b[0] came back True — but sharing looked harmless: strings can't change under you, so b is still safe to edit without ever touching a. And with flat rows of strings, every edit you try will even agree.import copy
a = [["Titanium", 245]]
b = a[:]
b[0].append("remix")
print(a)
c = copy.deepcopy(a)
c[0].append("live")
print(a)[['Titanium', 245, 'remix']] [['Titanium', 245, 'remix']]
a[:] photocopies only the outer rack of signposts — the inner list is still shared (that is how remix, pushed through b, leaked back into a), and only copy.deepcopy clones all the way down (which is why live never did).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, and it 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 — 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. That's 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. It 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.
append is the free doorway: one write into a spare seat, O(1), whatever the length.insert does the same job at a price — n − i references shuffle right before the item can land.extend takes an iterable, not an item. extend("abc") adds three slots, one per letter.+ builds a third list and rebinds the name. Any alias keeps pointing at the old, unchanged one.+= mutates in place, so every alias sees it, and id(lst) never changes — chapter 3’s whiteboard, exactly.you type
letters = ['a', 'b', 'c', 'd', 'e']
letters.append('f')
print(letters)
letters.insert(2, 'b2')
print(letters)
letters.extend(['x', 'y', 'z'])
print(letters)
letters.append(['p', 'q'])
print(letters)
print(len(letters))you see
['a', 'b', 'c', 'd', 'e', 'f']
['a', 'b', 'b2', 'c', 'd', 'e', 'f']
['a', 'b', 'b2', 'c', 'd', 'e', 'f', 'x', 'y', 'z']
['a', 'b', 'b2', 'c', 'd', 'e', 'f', 'x', 'y', 'z', ['p', 'q']]
11letters = letters.append('f')setslettersto None. A mutating method changes the row and hands back nothing..append(['x', 'y'])adds one slot holding a list..extend(['x', 'y'])adds two slots. Same argument, different shape..extend(5)raisesTypeError: 'int' object is not iterable. Extend needs something it can walk through.[1, 2] + "abc"raisesTypeError: can only concatenate list (not "str") to list.+refuses to mix types;extendaccepts..insert(1)raisesTypeError: insert expected 2 arguments, got 1. Insert always wants a position and an item.
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… The 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. Every slot is exactly 8 bytes wide and they sit shoulder to shoulder. So 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.
"Levels" in playlist gives you a bool and nothing else..index stops at the first match, so a song listed twice reports only its earliest slot.if 'c' in letters: before calling .index, and that habit is exactly right — a miss raises.len is the odd one out: O(1). The count sits in the list’s header, so nothing is walked at all.min, max and sum each read every slot once, so each is O(n) — the price of asking about values.you type
letters = ['a', 'b', 'c', 'c', 'd', 'e']
print('c' in letters)
print('f' in letters)
print(letters.index('c'))
print(letters.count('c'))
if 'f' in letters:
print(letters.index('f'))
else:
print("no 'f' in the row")
samples = [0, 12000, 24000, -6000]
print(len(samples), min(samples), max(samples), sum(samples))
print(sum(samples) / len(samples))you see
True
False
2
2
no 'f' in the row
4 -6000 24000 30000
7500.0letters.index('f')when'f'is absent raisesValueError: 'f' is not in list. Guard it withinfirst.min([])raisesValueError: min() iterable argument is empty, whilesum([])is a peaceful0.sum(["a", "b"])raisesTypeError: unsupported operand type(s) for +: 'int' and 'str'— sum starts at 0. Glue text with"".join(...).inis a walk, not a jump. On a million songs it measured ~6 ms against ~14 ns forlst[i]..countreturns0for a missing item and never raises — it is the safe way to ask "how many?" with no guard at all.
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. 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. →
playlist = ["Levels", "Titanium", "Levels", "Faded", "Titanium", "Levels"] — six entries, three real songs. Write a program that builds a new list holding each song once, in the order it first appeared. You already have every piece: a for loop from chapter 5, in to ask the question, .append to answer it. Then try the tempting one-liner, list(set(playlist)), and run it twice. The duplicates go — and so does the order, because a set has no slots and no positions, so it cannot remember which song came first. Finally, ask what your loop actually costs. Every song not in unique walks unique from the start, so the honest version is O(n²) on a long playlist. Fix that last, with a set used only for the asking and a list used only for the order.show the solution
playlist = ["Levels", "Titanium", "Levels", "Faded", "Titanium", "Levels"]
# THE HONEST WAY -- ask "have I got it already?" before appending
unique = []
for song in playlist:
if song not in unique:
unique.append(song)
print(unique) # ['Levels', 'Titanium', 'Faded']
print(len(playlist), "in,", len(unique), "out") # 6 in, 3 out
# THE ONE-LINER PEOPLE REACH FOR -- and what it silently costs you
print(sorted(set(playlist))) # ['Faded', 'Levels', 'Titanium']
# sorted() is here only so this page can show a fixed answer. Plain
# list(set(playlist)) comes back in an ARBITRARY order that changes
# between runs -- a set has no slots, so it cannot remember arrival.
# SAME ORDER, MUCH FASTER -- a set for the asking, a list for the order
seen = set()
unique_fast = []
for song in playlist:
if song not in seen: # asking a set is O(1); asking a list is O(n)
seen.add(song)
unique_fast.append(song)
print(unique_fast) # ['Levels', 'Titanium', 'Faded']
print(unique == unique_fast) # Truebase + 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. 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, which is 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.
pop(i) and del lst[i]. You have to know where the song sits.remove(x). You have to know what it is — and only the first match goes.pop is the one remover that hands the item back, so song = queue.pop(0) is a whole line’s work in three words..sort() and .reverse() rewrite the slots you already have, and build nothing new.lst.sort() against sorted(lst) is that law in two lines.you type
letters = ['a', 'b', 'c', 'c', 'd', 'e']
last = letters.pop()
print(last, letters)
second = letters.pop(1)
print(second, letters)
letters.remove('c')
print(letters)
del letters[0]
print(letters)
numbers = [5, 8, 31, 23, 1, 75, 22, 7]
print(numbers.sort())
print(numbers)
print(sorted([5, 8, 31], reverse=True))
numbers.clear()
print(numbers, len(numbers))you see
e ['a', 'b', 'c', 'c', 'd']
b ['a', 'c', 'c', 'd']
['a', 'c', 'd']
['c', 'd']
None
[1, 5, 7, 8, 22, 23, 31, 75]
[31, 8, 5]
[] 0x = lst.sort()puts None inx. The sorted row is inlst— there was never anything to catch..remove('c')deletes one'c', the first. Call it again for the second;.count('c')tells you how many are left..remove('f')when absent raisesValueError: list.remove(x): x not in list, and[].pop()raisesIndexError: pop from empty list..sort()on mixed types raisesTypeError: '<' not supported between instances of 'str' and 'int'. A row may be mixed; a sort may not.- Never
removeorpopinside aforover the same list. The slots shift under the walker and it silently skips items — walklst[:]instead.
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.
# playlist_manager.py -- one queue, five methods, the state printed after every move
queue = ["Blinding Lights", "Titanium", "Levels"]
print("start ", queue)
queue.append("Faded") # onto the end -- the free doorway
print("append('Faded') ", queue)
queue.insert(0, "One More Time") # to the front -- everything slides right
print("insert(0, 'One More Time')", queue)
queue.extend(["Clarity", "Wake Me Up"]) # each item gets its own slot
print("extend(2 songs) ", queue)
now_playing = queue.pop(0) # pop is the one remover that hands it back
print("pop(0) ->", now_playing, " ", queue)
queue.remove("Titanium") # by value, first match only, returns None
print("remove('Titanium') ", queue)
encore = queue.pop() # the cheap end
print("pop() ->", encore, " ", queue)
print()
print("songs left :", len(queue))
print("Levels still queued?", "Levels" in queue)
print("running order:", " - ".join(queue))
start ['Blinding Lights', 'Titanium', 'Levels']
append('Faded') ['Blinding Lights', 'Titanium', 'Levels', 'Faded']
insert(0, 'One More Time') ['One More Time', 'Blinding Lights', 'Titanium', 'Levels', 'Faded']
extend(2 songs) ['One More Time', 'Blinding Lights', 'Titanium', 'Levels', 'Faded', 'Clarity', 'Wake Me Up']
pop(0) -> One More Time ['Blinding Lights', 'Titanium', 'Levels', 'Faded', 'Clarity', 'Wake Me Up']
remove('Titanium') ['Blinding Lights', 'Levels', 'Faded', 'Clarity', 'Wake Me Up']
pop() -> Wake Me Up ['Blinding Lights', 'Levels', 'Faded', 'Clarity']
songs left : 4
Levels still queued? True
running order: Blinding Lights - Levels - Faded - Clarity
playlist_manager.py and run it from the terminal. There is no cleverness here on purpose: every line does one thing to one list, then shows you the list. Read the output rather than the code. append puts Faded on the end and nothing else moves. insert(0, …) puts One More Time at the front and every other song slides one slot right — four shuffles for one arrival. extend adds two slots from a single argument, which is exactly where it parts company with append. Then watch pop(0): it hands the song back and removes it, which is the only reason now_playing has anything in it. remove("Titanium") hands back nothing at all, so there is no name to catch and none written. The last line is worth typing twice. " - ".join(queue) is a chapter 2 string method reading a chapter 6 container, and it works only because every slot holds a str. Two things to try. Swap queue.pop(0) for queue.pop() and see which song starts playing. Then add queue.remove("Nightcall") and read the ValueError — it names the exact move a missing song refuses to make.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. Then 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. →
Traceback (most recent call last):
File "grow.py", line 2, in <module>
playlist[3] = "Faded"
~~~~~~~~^^^
IndexError: list assignment index out of range= can — the next section's splice grows and shrinks lists in place.) The word assignment in the message is the tell that you were writing, not reading.='s job, growing is append's — playlist.append("Faded") builds the seat and fills it.Traceback (most recent call last):
File "ask.py", line 2, in <module>
print(playlist["Titanium"])
~~~~~~~~^^^^^^^^^^^^
TypeError: list indices must be integers or slices, not strbase + i × 8 needs an integer — so Python refuses before computing anything, because "Titanium" × 8 points nowhere."Titanium" in playlist or playlist.index("Titanium"); brackets that accept names are dict's trick, one chapter away.Traceback (most recent call last):
File "push.py", line 2, in <module>
queue.push("Faded")
^^^^^^^^^^
AttributeError: 'list' object has no attribute 'push''list'), so it is really telling you which object doesn't know the word, not that your code is broken elsewhere.append (one item) or extend (many); pop is its opposite — it takes the last item back off.playlist = ["Titanium", "Levels", "Blinding Lights", "Faded"], then ordered = playlist.sort(), then print(ordered[0]). You expected the alphabetical winner. What you get is TypeError: 'NoneType' object is not subscriptable, and the traceback points at a line that is not where the mistake happened. Before you fix anything, print ordered on its own and read what it actually holds. Then fix it twice, because the two fixes mean different things. One keeps .sort() and uses the list it just rearranged. The other reaches for sorted(), which builds a second list and leaves the original order standing. Prove the difference with is rather than == — only is can tell you whether a second list was ever built.show the solution
# THE BUG ----------------------------------------------------- playlist = ["Titanium", "Levels", "Blinding Lights", "Faded"] ordered = playlist.sort() print(ordered) # None print(playlist) # ['Blinding Lights', 'Faded', 'Levels', 'Titanium'] # print(ordered[0]) # TypeError: 'NoneType' object is not subscriptable # WHY: .sort() rearranges playlist IN PLACE and hands back None. The # sorted row is in playlist itself -- there was nothing to catch. # FIX 1 -- mutate, then use the list you just mutated ---------- playlist = ["Titanium", "Levels", "Blinding Lights", "Faded"] playlist.sort() print(playlist[0]) # Blinding Lights # FIX 2 -- ask for a copy, and keep the original order --------- playlist = ["Titanium", "Levels", "Blinding Lights", "Faded"] ordered = sorted(playlist) print(ordered[0]) # Blinding Lights print(playlist[0]) # Titanium -- the original never moved print(ordered is playlist) # False -- a second list really was built
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 it 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. That's 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.
item[0:2] hands back two slots, 0 and 1. Read b as the wall you stop at.[:2] starts at the front, [3:] runs to the end, [:] is the whole shelf.number[::2] takes every other slot; number[::-1] walks the row backwards.= is a scalpel, not a photocopier. It splices into the original, and the counts need not match.del lst[a:b] removes the stretch in one bulk move, where a loop of remove calls would pay the shift over and over.you type
item = ['a', 'b', 'c', 'd']
print(item[0:2])
print(item[0:])
print(item[:2])
print(item[::2])
number = list(range(10))
print(number[::2])
print(number[::-1])
playlist = ["Blinding Lights", "Titanium", "Levels", "Faded"]
playlist[1:3] = ["Clarity"]
print(playlist)
playlist[1:1] = ["Wake Me Up", "One More Time"]
print(playlist)
del playlist[1:3]
print(playlist)
print(playlist[2:99])you see
['a', 'b']
['a', 'b', 'c', 'd']
['a', 'b']
['a', 'c']
[0, 2, 4, 6, 8]
[9, 8, 7, 6, 5, 4, 3, 2, 1, 0]
['Blinding Lights', 'Clarity', 'Faded']
['Blinding Lights', 'Wake Me Up', 'One More Time', 'Clarity', 'Faded']
['Blinding Lights', 'Clarity', 'Faded']
['Faded']lst[99]raisesIndexError;lst[99:]quietly hands back[]. Slices forgive, indices do not.lst[1:3] = "ab"splices two characters, not one string. The right side is always iterated — wrap it as["ab"].- Extended-slice assignment must match:
n[::2] = [9, 9, 9]on four items raisesValueError: attempt to assign sequence of size 3 to extended slice of size 2. b = a[:]copies the shelf, never the songs.b[0] is a[0]comes back True — a new container over shared contents.- A backwards slice needs a backwards stride.
nums[5:2]is empty; what you wanted wasnums[5:2:-1], which gives[5, 4, 3].
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. The green shelf underneath is the exact new list Python hands back.
a and b — watch the photocopy roll out▸ drag meb 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.
# sort_vs_sorted.py -- the dot changes the list; the function hands back a new one
titles = ["Faded", "One More Time", "Titanium", "Levels", "Blinding Lights"]
print("original :", titles)
by_name = sorted(titles)
print("sorted(titles):", by_name)
print("original still:", titles)
by_length = sorted(titles, key=len)
print("key=len :", by_length)
lengths = []
for t in by_length:
lengths.append(len(t))
print("their lengths :", lengths)
longest_first = sorted(titles, key=len, reverse=True)
print("longest first :", longest_first)
result = titles.sort()
print("titles.sort() returned:", result)
print("titles is now :", titles)
titles.sort(key=len, reverse=True)
print("in-place :", titles)
titles.reverse()
print("reverse() :", titles)
original : ['Faded', 'One More Time', 'Titanium', 'Levels', 'Blinding Lights'] sorted(titles): ['Blinding Lights', 'Faded', 'Levels', 'One More Time', 'Titanium'] original still: ['Faded', 'One More Time', 'Titanium', 'Levels', 'Blinding Lights'] key=len : ['Faded', 'Levels', 'Titanium', 'One More Time', 'Blinding Lights'] their lengths : [5, 6, 8, 13, 15] longest first : ['Blinding Lights', 'One More Time', 'Titanium', 'Levels', 'Faded'] titles.sort() returned: None titles is now : ['Blinding Lights', 'Faded', 'Levels', 'One More Time', 'Titanium'] in-place : ['Blinding Lights', 'One More Time', 'Titanium', 'Levels', 'Faded'] reverse() : ['Faded', 'Levels', 'Titanium', 'One More Time', 'Blinding Lights']
sort_vs_sorted.py. The whole program exists to make one distinction impossible to forget. sorted(titles) builds a second list, and the third line proves the original is untouched. titles.sort() rearranges the list you already have and hands back None — which the program prints out loud, so you never have to wonder again. That is Ajai’s law from his notes, in one file: a dot on the object changes the object, the object inside a function comes back as something new. Now the key. key=len does not sort the titles at all; it runs len on each one and sorts by those numbers. Notice there are no parentheses after len — you hand sorted the function itself, not the result of calling it, and sorted calls it once per title. The lengths line prints [5, 6, 8, 13, 15] so you can see the proxy values it actually compared. reverse=True flips the finished order, and works on both forms. One thing to try: add "faded" in lower case and sort twice, plain and then with key=str.lower. Plain sorting puts every capital before every lower-case letter, because it compares code points, not letters.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. The bare index playlist[99], by contrast, 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? 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?
nums = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9] and write down what each of these prints. A: nums[2:5]. B: nums[::3]. C: nums[-3:]. D: nums[5:2]. Exactly one of the four comes back empty, and if you cannot say which without running it, that is the drill working. Then the splice. Run nums[2:5] = ["x"] and predict both the new row and its new length before you print either — three slots left, one arrived, and the list got shorter without a single remove call. Last, del nums[:2]. Now ask the only question slicing ever really asks: which of these lines built a new list, and which reached back into the old one?show the solution
nums = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9] print(nums[2:5]) # A: [2, 3, 4] stop is exclusive -- slot 5 is the wall print(nums[::3]) # B: [0, 3, 6, 9] every third slot, from the front print(nums[-3:]) # C: [7, 8, 9] the last three, counted from the back print(nums[5:2]) # D: [] start past stop with a +1 stride print(nums[5:2:-1]) # [5, 4, 3] a backwards slice needs a backwards stride nums[2:5] = ["x"] # THE SPLICE: three slots out, one in print(nums) # [0, 1, 'x', 5, 6, 7, 8, 9] print(len(nums)) # 8 -- two shorter, and no remove() in sight del nums[:2] # delete a whole stretch in one move print(nums) # ['x', 5, 6, 7, 8, 9]
Meet the tuple: the sealed track, and how to crack one open. →
grid[1][2] is not special syntax. grid[1] hands you a list, and [2] indexes that list. Two ordinary lookups.grid[0][0] = 99 changes one slot of one inner row, and nothing else.for walks rows outside, cells inside. Nothing enforces a rectangle — ragged rows are perfectly legal.[0] * 3 is safe because an int can never be edited. Three slots share one object that nobody can change.[[0] * 3] * 2 is the trap. The outer * 2 copies the reference, so both rows are one list wearing two signposts.you type
grid = [[1, 2, 3], [4, 5, 6]]
print(grid[1][2])
grid[0][0] = 99
print(grid)
for row in grid:
for cell in row:
print(cell, end=" ")
print()
bad = [[0] * 3] * 2
bad[0][0] = 7
print(bad)
good = []
for _ in range(2):
good.append([0] * 3)
good[0][0] = 7
print(good)
print(bad[0] is bad[1], good[0] is good[1])you see
6
[[99, 2, 3], [4, 5, 6]]
99 2 3
4 5 6
[[7, 0, 0], [7, 0, 0]]
[[7, 0, 0], [0, 0, 0]]
True Falsebad[0][0] = 7changes both rows, becausebad[0] is bad[1]is True. One list, two addresses — §01’s rack, biting.- Build the rows in a loop instead:
for _ in range(2): good.append([0] * 3). Each lap allocates a genuinely new list. grid[1, 2]raisesTypeError: list indices must be integers or slices, not tuple. Two brackets, never one comma.grid[:]copies the outer shelf only. The inner rows stay shared — the same shallow copy this section just took apart.- A grid is not a matrix.
len(grid)is the row count;len(grid[0])is the width of that row alone, and rows may differ.
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. It never fills up, because 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 this runs 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. That's 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. That 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. That one seat is 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. 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. First, there can be only one starred name per unpacking. Two stars and 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). Second, it can sit anywhere in the row: front, middle, or back. And third, 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.
track = "Blinding Lights", "The Weeknd", 200 is a tuple, parens or no parens.(4) is the int 4 wearing grouping parens; (4,) is a tuple of one. The comma is the entire grammar.title, artist, seconds = track. The counts are law, as they were in chapter 3.a, b = b, a needs no temporary because the right side is packed into a tuple before a single name moves.you type
track = ("Blinding Lights", "The Weeknd", 200)
title, artist, seconds = track
print(title, "|", artist, "|", seconds)
one = (4)
real_one = (4,)
print(type(one), type(real_one), len(real_one))
pair = "Titanium", 245
print(pair, type(pair))
a, b = "Blinding Lights", "Titanium"
a, b = b, a
print(a, b)
first, *rest = track
print(first, rest, type(rest))
items = [('apples', 15), ('oranges', 12), ('pear', 20)]
total = 0
for name, price in items:
total = total + price
print(total)you see
Blinding Lights | The Weeknd | 200
<class 'int'> <class 'tuple'> 1
('Titanium', 245) <class 'tuple'>
Titanium Blinding Lights
Blinding Lights ['The Weeknd', 200] <class 'list'>
47t[0] = "x"raisesTypeError: 'tuple' object does not support item assignment. The seal is on the slots.t.append("Levels")raisesAttributeError: 'tuple' object has no attribute 'append'— there is nowhere to append to.x, y = (1, 2, 3)raisesValueError: too many values to unpack (expected 2), checked before a single name moves.t += ("Levels",)runs and looks like growth. It builds a new tuple and repoints the name;id(t)changes.- A tuple is hashable only if everything inside it is.
hash(("a", [1]))raisesTypeError: unhashable type: 'list'.
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, so 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. →
- A list never holds your songs — it is a contiguous row of 8-byte signposts, one reference per slot, with the objects themselves scattered across the heap.
appendstays almost free because spare seats are already paid for — the rare moving day, spread across the cheap runs it buys, averages out to amortized O(1).- Indexing is arithmetic, not a search:
base + i × 8lands on slotiin one hop, the same cost at any index — slot three or slot three million. - The middle charges rent: strike at index
iand n − i neighbours must slide — so build at the tail, where nothing has to move. - A slice photocopies one layer of addresses — shallow, shared beneath — and a tuple seals its row shut at birth: once everything inside is frozen too, that seal is what earns it a hashable key.
# setlist.py -- the setlist editor: a menu that loops until you quit
# after Ajai's own "To Do List (Simple)" project, rebuilt on chapter 5's
# while + if and chapter 6's list methods. No functions yet -- chapter 9.
setlist = []
while True:
print()
print("===== SETLIST =====")
print("1. add a song")
print("2. remove a song")
print("3. show the setlist")
print("4. quit")
choice = input("choose 1-4: ").strip()
if choice == "1":
song = input("song title: ").strip()
if song == "":
print("a title cannot be empty.")
elif song in setlist:
print(f"already in at position {setlist.index(song) + 1}: {song}")
else:
setlist.append(song)
print(f"added {song} - {len(setlist)} song(s) queued")
elif choice == "2":
if not setlist:
print("nothing to remove - the setlist is empty.")
else:
song = input("which song? ").strip()
if song in setlist:
setlist.remove(song)
print(f"removed {song} - {len(setlist)} left")
else:
print(f"not in the setlist: {song}")
elif choice == "3":
if not setlist:
print("the setlist is empty.")
else:
print(f"-- {len(setlist)} song(s), running order --")
for i, song in enumerate(setlist, start=1):
print(f"{i}. {song}")
elif choice == "4":
print("final setlist:", " - ".join(setlist) if setlist else "(empty)")
break
else:
print("please type 1, 2, 3 or 4.")
===== SETLIST ===== 1. add a song 2. remove a song 3. show the setlist 4. quit choose 1-4: 1 song title: Blinding Lights added Blinding Lights - 1 song(s) queued ===== SETLIST ===== 1. add a song 2. remove a song 3. show the setlist 4. quit choose 1-4: 1 song title: Titanium added Titanium - 2 song(s) queued ===== SETLIST ===== 1. add a song 2. remove a song 3. show the setlist 4. quit choose 1-4: 1 song title: Levels added Levels - 3 song(s) queued ===== SETLIST ===== 1. add a song 2. remove a song 3. show the setlist 4. quit choose 1-4: 3 -- 3 song(s), running order -- 1. Blinding Lights 2. Titanium 3. Levels ===== SETLIST ===== 1. add a song 2. remove a song 3. show the setlist 4. quit choose 1-4: 2 which song? Levels removed Levels - 2 left ===== SETLIST ===== 1. add a song 2. remove a song 3. show the setlist 4. quit choose 1-4: 4 final setlist: Blinding Lights - Titanium
while True and if/elif, chapter 6’s list methods. His version stored a dictionary per task and split the work across functions; both of those arrive later, in chapters 7 and 9. Here one list holds everything. Read the loop first. while True is an infinite loop with exactly one way out — the break under choice 4 — and every other branch simply falls to the bottom and draws the menu again. Then read the guards, because a real program is mostly guards. An empty title is refused. A duplicate is refused, and .index reports where the original already sits. Removing from an empty setlist is refused before remove gets a chance to raise. not setlist is the idiom for that last one: an empty list is falsy, so the test reads exactly like the sentence it means. The session above is a real captured run; yours will differ, and that is the point. Three things to break. Type 9 and watch the final else catch it. Add the same song twice and read the position it hands back. Then delete the elif song in setlist: branch, add one song three times, and remove it once — you will find two copies still queued, because remove only ever takes the first.() 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 that each pin down one move — press Next and watch the row change under your hands.