python/volume-4/chapter-49ch 49 / 64 · 11 min

49The array — the block everything is built on

In Chapter 48 we said a data structure is two things joined together: a way of arranging data in memory, and the operations that arrangement makes cheap. Now we spend that idea on the first arrangement there is, the one every other structure in this volume builds on: the array, a contiguous block of equal-size slots laid end to end in RAM with no gaps. Here's the plan. We'll set that block down in memory, watch the machine reach any element with a single multiply-add, then feel the price the same tight packing charges the moment you insert in the middle. All the way through, we keep asking the one question that decides everything: what does the layout make cheap, and what does it make expensive? By the end you'll see why indexing is O(1) and a middle insert is O(n) — not as rules to memorise, but as facts the layout leaves no choice about. You'll also see why Python's list grows in sudden jumps, why a list of a million integers costs 36 MB while the same numbers packed cost 8, and when the array is the container to reach for. Which is more often than you'd guess.

One block, equal slots, no gaps Why indexing is O(1): one multiply-add The price of the packing: the shift Static vs dynamic: how a fixed block grows
scroll to begin

01One block, equal slots, no gaps

Let's start from the metal. Back in Volume 1 we drew RAM as one long row of numbered boxes: each box one byte, each with an address, its number in the row. An array is the simplest way to lay a sequence into that row. Pick a slot size, then set the elements down in consecutive slots starting at some address. That starting address is the array's base. The slot size — how many bytes each element takes — is the stride. Notice what's not there: nothing marks where one element ends and the next begins, except the fact that they're all the same width. So the machine can find element i by counting. Skip i strides from the base, and you're standing on it.

The word doing all the work is contiguous — "touching, in one unbroken run." The slots are neighbours in physical memory. There's no header between them, no pointer from one to the next, no bookkeeping of any kind. That absence is the whole trick, and everything good and bad about the array flows straight out of it.

one contiguous block · equal 8-byte slots · consecutive addresses index address 0 1 2 3 4 5 200 247 245 269 210 258 1000 1008 1016 1024 1032 1040 +8 bytes (stride) ↑ base Addresses shown in decimal for clarity — the real ones look like the 2,606,972,461,072 we measured below.
Fig — A six-element array of track lengths. Same-width slots at consecutive addresses; the index is not stored anywhere — it is computed from base and stride.
Two numbers define an array
A base address (where slot 0 sits) and a stride (bytes per slot). Everything else — every element's location — is arithmetic on those two. Hold that thought: it's the reason the next section is titled "one multiply-add."

If the position of element i is nowhere written down, how does the machine find it instantly? It doesn't look. It calculates →

02Why indexing is O(1): one multiply-add

Here is the array's superpower, and it's pure arithmetic. To find element i, the machine computes its address directly:

address(i) = base + i × stride

One multiply, one add. The CPU forms that address and reads the slot. There's no scanning, no comparing, no walking from the front. It doesn't matter whether i is 0 or 9,999,999: the same two operations land on the exact byte. That is what O(1) — constant time — means. The cost doesn't grow with the size of the collection. This is the payoff Volume 1 promised when it called a Python list "a contiguous array of references": indexing is a jump, not a search.

We can watch the formula on real memory. The array module hands us a block of packed 8-byte slots, and it will report the true base address of slot 0:

index.pypython
import array
A = array.array('q', [10, 20, 30, 40, 50])   # 'q' = signed 8-byte slots
base, length = A.buffer_info()                # the REAL address of slot 0
for i in range(length):
    print(i, base + i * A.itemsize)           # itemsize == stride == 8
# 0 2606972461072
# 1 2606972461080   <- exactly 8 bytes on from slot 0
# 2 2606972461088
# 3 2606972461096
# 4 2606972461104

Line 3 asks the array for its base address — an actual location in this process's RAM. Line 5 applies the formula: slot i sits at base + i × 8. The printed addresses climb by exactly 8 each step, with no gaps. That's proof the slots are contiguous, and proof that reaching any one is a single multiply-add. The machine never touched slots 0–3 to find slot 4; it computed straight to it.

And it's flat on the clock, not just in theory. Watch what happens when we time index access on lists of wildly different sizes (the numbers depend on the machine, but the shape is the point):

index.pypython
import timeit
for n in (1_000, 100_000, 10_000_000):
    L = list(range(n))
    t = timeit.timeit(f"L[{n//2}]", globals={"L": L}, number=2_000_000)
    print(n, round(t/2e6*1e9, 1), "ns")
# 1000       13.9 ns
# 100000     13.7 ns
# 10000000   14.4 ns   <- a 10,000x bigger list, same access time

Reaching the middle of a thousand-element list and the middle of a ten-million-element list both cost about 14 nanoseconds on our machine. The list grew ten-thousand-fold, and the access time didn't budge. That is O(1) you can see on a stopwatch.

Myth

A bigger array takes longer to reach a deep element — element one-million must be "further in," so it costs more to get to.

Reality

Distance is free. base + i × stride is one multiply-add whatever i is; we measured ~14 ns to index the middle of both a 1,000- and a 10,000,000-element list. The array doesn't travel to the element — it computes where it already is.
The dark side of raw arithmetic
Because base + i × stride is just arithmetic, nothing stops the machine computing the address for an i past the end — it will happily read or write a neighbour's memory. That is the buffer overflow, the single most exploited bug class in computing history (the Morris worm, Heartbleed, countless CVEs). Low-level languages like C leave the door open. Python bolts it shut: every index is bounds-checked, and an out-of-range i raises IndexError instead of corrupting memory — a small tax on every access, paid for safety.
InteractiveSlide the index — watch the address compute, the slot light up
0 1 2 3 4 5 6 7 1000 1008 1016 1024 1032 1040 1048 1056 address = 1000 + 0 × 8 = 1000 one multiply, one add → the CPU jumps straight to the slot. no searching.
0
The cost never changes with i. That flatness is what O(1) looks like.

Instant reads sound like a free lunch. They aren't — the same tight packing that makes index a jump makes the middle a wall →

03The price of the packing: the shift

The array's contiguity is a promise: no gaps, ever. Reads love that promise. Inserts hate it. Suppose you want to slip a new track into the middle of the playlist, at position i. There's no empty slot waiting there. Slot i is occupied by its neighbour, and that neighbour by its neighbour, all the way to the end. To open a hole at i without breaking the "no gaps" promise, every element from i onward must slide one slot to the right. That's n − i elements moved for a single insertion. Deleting in the middle is the same thing in reverse: everything after the hole shifts left to close it.

So insertion and deletion are O(n): the cost grows with how much sits after the cut. Insert at the very end (i = n) and nothing shifts, so it's cheap. Insert at the front (i = 0) and all n elements move, the worst case. The measurement is clear. Inserting at the front of lists ten times larger costs ten times more:

cost.pypython
import timeit
for n in (10_000, 100_000, 1_000_000):
    L = list(range(n))
    t = timeit.timeit("L.insert(0, 99)", globals={"L": L}, number=1000)
    print(n, round(t/1000*1e6, 1), "us per front-insert")
# 10000        1.7 us
# 100000      17.1 us    <- 10x the elements, ~10x the shift
# 1000000    185.0 us    <- and again: linear, O(n)

Each L.insert(0, 99) forces CPython to shove every existing element up one slot before writing the new value into slot 0. As n goes 10k → 100k → 1M, the time goes 1.7 → 17 → 185 microseconds — a clean straight line. There's O(n), lived. (Appending to the end of these same lists, by contrast, runs in tens of nanoseconds. That's thousands of times faster, because it shifts nothing.)

insert at i = 2 → elements 2..5 shift right (n − i = 4 moves) 200 247 245 269 210 258 200 247 NEW 245 269 210 258 before after
Fig — To keep the "no gaps" promise, an insert at i must move the n − i tail elements over. Cheap reads, expensive middle edits — two faces of one coin.
InteractivePick where to insert — count how many elements have to move
insert at i = 0 → shifts = 8 − 0 = 8 elements move front insert = worst case: the whole array slides.
0
Blue stays put; amber must move. Drag i toward the end and the cost melts to zero — that's why append is the cheap edit.
Read-heavy: yes. Middle-churn: no.
If your workload indexes, scans, and appends, the array is close to unbeatable. If it constantly inserts and removes in the middle, every edit drags a tail of memory and the O(n) tax compounds — that's the exact pain the next chapter's linked list was invented to remove.

The array so far has a fatal-sounding flaw: a fixed size. Lay six slots and you've committed to six. So how does list — which you .append to forever — live on top of it? →

04Static vs dynamic: how a fixed block grows

A raw array is static: its length is fixed the moment it's allocated, because the slots after it may belong to something else. Python's list is a dynamic array — the same contiguous block, wrapped in a trick that lets it grow. The trick is overallocation. The list quietly asks for more slots than it currently needs, and keeps the spare capacity in reserve. Appending just writes into the next spare slot and bumps a length counter: O(1), no copying. Only when the spare runs out does the list allocate a bigger block, copy everything across, and free the old one.

You can watch the reserve fill and refill. sys.getsizeof reports the block's real byte size, and it only changes when the underlying capacity does:

dynamic.pypython
import sys
L, size = [], sys.getsizeof([])          # empty list header = 56 bytes
for i in range(40):
    L.append(i)
    s = sys.getsizeof(L)
    if s != size:                        # only prints when it RESIZED
        print(len(L), s, "bytes  capacity", (s - 56)//8)
        size = s
# 1   88 bytes  capacity 4
# 5  120 bytes  capacity 8
# 9  184 bytes  capacity 16
# 17 248 bytes  capacity 24
# 25 312 bytes  capacity 32
# 33 376 bytes  capacity 40

The empty list is a 56-byte header with zero slots. The first append jumps it to capacity 4 (88 = 56 + 4×8). Appends 2, 3, and 4 are then free: they fill the reserve without changing the size. The 5th append exhausts capacity 4, so CPython grows to 8, copies the four elements over, and carries on. The pattern of capacities — 4, 8, 16, 24, 32, 40 — shows the block growing by a shrinking proportion each time, roughly 1.125×, not doubling. Those gaps between resizes are why almost every append is cheap.

len = 5, but capacity = 8 → three spare slots absorb the next appends free 200 247 245 269 210 · · · used (len) spare capacity full → resize bigger block, copy all n
Fig — A dynamic array keeps spare slots so most appends are free writes. When the spare runs out it allocates a larger block and copies — rare, and paid back over many cheap appends.

Doesn't that copy-everything resize make append secretly O(n)? Sometimes it does — a single append that triggers a resize really does move all n elements. But it happens so rarely (only when capacity fills) that spread across all the appends, the average cost stays constant. That's amortized O(1), the honest-average accounting from Volume 3. Timing the whole build proves it: the per-append cost stays flat even as the list grows 100×.

dynamic.pypython
# build a list of n by appending, then divide total time by n
100_000  appends:  ~28 ns each
1_000_000 appends:  ~38 ns each
10_000_000 appends: ~40 ns each     <- flat: amortized O(1) per append
InteractiveAppend one at a time — watch capacity jump, not creep
len = 0 · capacity = 0 · getsizeof = 56 bytes empty list: just the 56-byte header.
0
getsizeof holds steady, then leaps — every leap is a resize that copied all n. Between leaps, appends are free.
↺ The thing people get backwards
"Arrays are slow to insert, so avoid them." Half-true, and the half that's wrong costs people the fastest structure they have. Arrays are slow to insert in the middle. Appending to the end — which is what real code does the overwhelming majority of the time — is amortized O(1), and it streams through cache while it does it. The right reflex isn't "avoid arrays," it's "avoid middle edits on arrays." Reach for something else only when middle churn is your actual pattern.
The deeper cut — CPython's exact growth rule, and why not 2×

When a CPython list must grow, it doesn't double. The rule (in list_resize) is roughly new_allocated = newsize + (newsize >> 3) + 6, then rounded up. That's about 1.125× plus a constant, which is exactly the 4, 8, 16, 24, 32, 40 progression we measured. Why so modest? Doubling wastes up to half the block as permanent slack. A gentle 1.125× keeps memory tight while still leaving enough headroom that resizes become geometrically rarer as the list grows, so it preserves amortized O(1) without the memory bloat. It's a deliberately different trade from, say, C++'s std::vector, which typically doubles (faster growth, more slack). The header, by the way, is a fixed 56 bytes: the PyListObject's refcount, type pointer, length, capacity, and a pointer to the slot block, which lives separately. getsizeof counts the header plus the slot block, but not the objects the slots point to. That's the whole next section.

That last line hides the biggest surprise in this chapter. A Python list of a million numbers is far heavier than a million numbers — and the reason is what the slots actually hold →

05References vs values: why a list weighs 4× an array

Here's the twist that trips up almost everyone. A Python list's slots do not hold your numbers. They hold references — 8-byte addresses (Volume 1: a name is a reference) pointing to full int objects that live scattered elsewhere on the heap. Each of those integer objects carries the ~28-byte object header every Python object pays (refcount, type pointer, the digits). So one integer in a list costs you two things: 8 bytes in the contiguous slot block, plus ~28 bytes for the boxed object it points at. The block itself is tidy and contiguous, but the values it references are strewn across memory.

The array module offers the other layout: a packed array that stores the raw values directly, with no per-element object and no reference indirection. The size difference is not subtle. We measured it:

packing.pypython
import sys, array
n = 1_000_000
L = list(range(n))
A = array.array('q', range(n))            # packed 8-byte values

sys.getsizeof(L)                          # 8_000_056  → just the references
sum(sys.getsizeof(x) for x in set(L))     # 28_000_000 → the boxed ints behind them
sys.getsizeof(A)                          # 8_183_816  → raw values, no boxing

The list's slot block is ~8 MB — a clean million 8-byte references. But follow those references and you find a million 28-byte int objects: another ~28 MB. The list's true footprint is about 36 MB (≈36 bytes per element). The packed array holds the identical numbers in ~8.2 MB (~8 bytes each). Same data, 4.4× less memory, because the array pays no object tax and stores no pointers — just values back to back.

list — a contiguous block of references → scattered int objects 8 B each (references) int 200 · 28 B int 247 · 28 B int 245 · 28 B int 269 · 28 B ≈ 36 MB array('q') — raw values packed back to back, no objects, no pointers 200 247 245 269 210 8 B each · nothing else ≈ 8.2 MB
Fig — The list's slot block is contiguous, but its values are scattered 28-byte objects reached by pointer. The packed array stores the values themselves — 4.4× lighter for the same million numbers.
Where you meet this
Every time you do numeric work at scale you're choosing packed over boxed. NumPy arrays, pandas columns, PyTorch / TensorFlow tensors, an image (a packed height × width × 3 byte array), an audio buffer, a database page, a network packet — all packed contiguous blocks, precisely to dodge the 4× object tax and to stay cache-friendly. Python's flexible boxed list is the right default for mixed, small collections; the moment it's a million homogeneous numbers, you pack.

Lighter is only half the win. The packed block is also the shape the CPU physically runs fastest over — and that's not a metaphor, it's the cache →

06Why the metal loves a block: cache locality

Big-O counts operations and treats every memory access as one unit. The hardware disagrees, and the array is where the disagreement pays off. Recall the memory hierarchy from Volume 3: an L1 cache hit costs ~1 ns, a miss out to RAM ~100 ns, and the CPU never fetches one byte at a time. On every miss it hauls in the whole surrounding cache line, ~64 bytes. For a packed array of 8-byte values that's eight neighbours per fetch: one miss, then seven free hits. Walk the block in order and the prefetcher, seeing the straight march, fetches the next line before you ask. A contiguous scan is, quite literally, the fastest thing a CPU does.

To prove the speed comes from the layout and nothing else, walk the same block twice: once in order, once through a shuffled set of indices. The reads are identical and the count is identical. Only the address pattern differs:

cache.pypython
import timeit, random
n = 5_000_000
data = list(range(n))
seq  = list(range(n))                     # visit 0,1,2,... in order
rnd  = list(range(n)); random.shuffle(rnd)   # same indices, shuffled
def walk(order):
    s = 0
    for i in order: s += data[i]
    return s
# walk(seq): ~140 ms
# walk(rnd): ~1139 ms   → 8.1x slower, exact same reads

Both loops touch every element exactly once and add it up: same n, same O(n), same interpreter overhead per step. The only difference is that seq marches through consecutive addresses (cache-friendly) while rnd leaps to random ones (a miss almost every time). The result: the ordered walk ran ~ faster on our machine. That gap isn't in the operation count, since Big-O is identical. It's the constant the layout bakes in, and it's why the contiguous array beats a scattered structure with the same complexity.

one 64-byte cache-line fetch = eight packed values v0 v1 v2 v3 v4 v5 v6 v7 sequential: 1 miss → 7 free hits; prefetcher grabs the next line early. random order jumps to a fresh line almost every access → a miss each time, 7/8 of each fetch wasted. measured (5,000,000 reads, same indices, order only): sequential ≈ 140 ms random ≈ 1139 ms — 8.1× slower, same reads
Fig — Same reads, same O(n); only the access order differs. Contiguity lets one fetch serve eight elements and lets the prefetcher run ahead — the array's quiet, decisive advantage.
InteractiveGrow the array past cache — watch one read fall off the cliff
one random read — where the data lives decides its speed working-set size → L1 L2 L3 DRAM 4 KB32 KB1 MB8 MB256 MB time for that single read (linear scale, nanoseconds): 1 4 14 90 ns 1.1 ns the baseline — reads hit L1 at ~1 ns fits in L1 cache — the read is essentially free Same array, same random access, same O(1) — only cache residency changed.
16 KB
L1 is a pen on your desk (~1 ns); DRAM is mailing off for it (~90 ns). Drag past ~8 MB and every read falls off the cache cliff — that invisible line, not the Big-O, is what decides real-world speed.
The bet the hardware makes for you
Store data in a block and walk it in order, and the cache line plus the prefetcher turn RAM's ~100 ns into something close to L1's ~1 ns. The array is the structure that rewards that bet by construction — which is why, at equal Big-O, "just use an array" is so often the fastest answer. It's also why C++'s std::vector beats std::list at nearly everything, and why every high-performance numeric library is built on packed blocks.

Light, instant to index, cache-loving, cheap to append. It's no accident this is where every other structure begins →

07The 1% move: the array is the default, and the foundation

Being "good at data structures" is mostly knowing which one fits the access pattern, and the array is the default, the one others must earn their way past. Here's its profile: O(1) indexing, O(1) amortized append, unbeatable cache behaviour, and minimal overhead (packed: ~8 bytes/element; boxed list: ~36). In exchange, you pay O(n) middle inserts and deletes, and, when packed, a fixed element type. So the rule is sharp: if you access by position, iterate in order, and grow at the end, use an array. Reach for something else only when your dominant operation is middle churn, keyed lookup, or hierarchical order — and the chapters ahead are exactly those "something elses."

The human insight worth taking with you is the one that made all of it possible: turn position into arithmetic. Someone realised that if you fix the slot size, an element's location stops being something you store and search for and becomes something you compute: base + i × stride, one multiply-add. That single idea — address as a formula — is the seed of the whole field. A stack and a queue are arrays with a rule about which end you touch. A heap is a tree flattened into an array, where a node's children are found by arithmetic (2i+1, 2i+2). A matrix is an array indexed by row × width + col. Even a hash table (Volume 1) is an array whose index you compute from the key. Learn to see the block under all of them, and the rest of this volume stops being thirteen unrelated tricks and becomes variations on one move.

hash table heap matrix stack queue string one contiguous block · address = base + i × stride
Fig — Almost every structure in this volume is the array in disguise — a block, plus a rule for how to compute or interpret its indices. Learn the block and you've learned the foundation.

The everyday version is real too. A parking garage numbers its spaces so you find yours by arithmetic instead of wandering. A packed shelf shoves every book down when you wedge one into the middle. The array is the oldest idea in computing because it's the most human one: put things in a row of equal boxes, and you'll always know where each one is.

Wait —
everything good about the array came from contiguity: pack the slots tight and position becomes arithmetic. But what if you gave that up on purpose — let each element live anywhere in memory, and store a pointer from each to the next? You'd lose the O(1) index. What could possibly be worth that?

The next chapter unbolts the block. The linked list throws away contiguity, scatters its elements as nodes joined by pointers, and buys back the one thing the array can't do cheaply — O(1) insertion in the middle — at the price of the multiply-add that made indexing free. Two structures, opposite deals with memory. →

PROGRAMS run them line by line — the same ideas, written the many ways Python offers
Chapter 49, in working code

The array is the oldest idea in computing, and the quietest: put things in a row of equal boxes and you never search for one — you compute where it already is. Here are twelve tiny runnable proofs of that single move, and everything it buys.

Position becomes arithmetic — O(1) indexing
Fix the slot size and an element's location stops being something you store and search for — it becomes something you compute: base + i × stride, one multiply-add, the same cost for slot 0 or slot nine-million.
The price of the packing — the shift
Contiguity is a promise of no gaps, and reads love it. Middle inserts and deletes hate it: to keep the promise, every element past the cut must slide, so both are O(n).
A fixed block that learns to grow
A raw array's length is frozen at birth. Python's list wraps it in overallocation — spare slots that make almost every append free, with rare copy-everything resizes that average out to amortized O(1).
Lighter, and the shape everything else is built on
Packed values dodge the 4× object tax a boxed list pays — and once you see position as arithmetic, the matrix and the heap turn out to be the same block wearing a different formula.
end of chapter 49 · seven sections
iolinked.com
Written by Ajai Raj