What Python Lists Really Cost
append vs insert(0), pop() vs pop(0), + vs comprehension — measured, not guessed.
- say what the common list operations cost, and why;
- explain why
appendis cheap andinsert(0, x)is not; - explain what "amortised O(1)" means and why append earns it;
- measure the difference yourself instead of taking my word for it;
- rewrite a slow list-building loop into a fast one;
- read a cost table for a data structure and use it to make a choice.
10.1What a list really is
Picture a row of numbered lockers, side by side, with no gaps. That is a Python list: a block of slots in order, each holding a reference to a value.
Two consequences follow from the "no gaps" part, and they explain everything else:
-
Reading a position is instant. To find item 4 719, the computer
computes where the slot is and goes there. Length is irrelevant:
O(1). -
Inserting or removing at the front is expensive. If you take out the
first locker, everything behind it has to shuffle forward one place to close the gap.
That is n moves:
O(n).
Adding at the end is like putting a book on the end of a shelf. Adding at the front is like inserting a book at the start of a full shelf: every other book moves. Same act, entirely different price.
10.2The cost table
| Operation | Example | Cost | Why |
|---|---|---|---|
| index read / write | data[i] | O(1) | Jump straight to the slot |
| length | len(data) | O(1) | Python keeps a running count |
| append | data.append(x) | O(1)* | Write into the next free slot |
| pop from end | data.pop() | O(1) | Nothing has to move |
| insert at front | data.insert(0, x) | O(n) | Everything shifts right |
| pop from front | data.pop(0) | O(n) | Everything shifts left |
| membership | x in data | O(n) | Look at items until found |
| delete by value | data.remove(x) | O(n) | Find it, then shift |
| slice | data[a:b] | O(k) | Copies k items into a new list |
| concatenate | a + b | O(n+m) | Builds a whole new list |
| sort | data.sort() | O(n log n) | See week 13 |
| min / max / sum | max(data) | O(n) | Must inspect everything |
* append is O(1) amortised: occasionally Python has to move the
whole list into a bigger block of memory, which costs O(n), but it doubles the space
each time so those events are rare enough to average out to a constant. The next
section watches that happen.
10.3Amortised append: watching the list grow
The lockers picture has one loose end: what happens when the row is full and you append one more? There is no free slot behind the last one. Python's answer is to rent a bigger row — typically about double the size — copy every existing item across, and then write the new one. That copy is O(n). So how can we call append cheap?
Because the expensive copies are rare, and they get rarer as the list grows. Suppose the list doubles its capacity at sizes 1, 2, 4, 8, 16 … To append n items, the total copying work is 1 + 2 + 4 + … + n, which comes to less than 2n — a constant amount of copying per append, spread out over all of them. Sharing the cost of the rare big events across the many cheap ones is called amortising, and it is why we quote append as O(1).
You can see the capacity jumps directly. sys.getsizeof reports how many
bytes a list occupies; watch it step up in chunks, not smoothly, as we append:
import sys
data = []
last = -1
for i in range(17):
size = sys.getsizeof(data)
if size != last: # only print when it jumps
print(f"len={len(data):>2} {size} bytes <- grew here")
last = size
else:
print(f"len={len(data):>2} {size} bytes")
data.append(i)Most appends land in already-rented space and cost nothing extra — those are the flat stretches. Only at a jump does Python copy the list into a bigger block, and each block lasts longer than the last, so the jumps thin out. Averaged over the whole run, the cost per append settles to a constant. That is exactly what "O(1) amortised" means: usually cheap, occasionally dear, constant on average.
"Amortised" is the honest word for "individual calls can spike, but the long-run average is constant". It is different from plain O(1) (never spikes) and from worst-case O(n) (which would be the wrong headline for append, since the spikes are too rare to matter). When someone asks the cost of append, "O(1) amortised" is the full and correct answer.
10.4Measuring it: front versus back
Never take a table on trust, this one included. Build the same list two ways:
import time
def build_append(n):
data = []
for i in range(n):
data.append(i) # cheap end
return data
def build_insert_front(n):
data = []
for i in range(n):
data.insert(0, i) # expensive end
return data
for n in [10000, 20000, 40000, 80000]:
start = time.perf_counter(); build_append(n)
t_app = time.perf_counter() - start
start = time.perf_counter(); build_insert_front(n)
t_ins = time.perf_counter() - start
print(f"n={n:>6} append {t_app:.4f}s insert(0) {t_ins:.4f}s ratio {t_ins/t_app:,.0f}x")
Read the ratio column: it doubles every time n doubles. That is the fingerprint of one
method being O(n) overall while the other is O(n²) — because n insertions at
O(n) each is n × n. The two loops look almost identical on
the page. One of them is a bomb with a slow fuse.
10.5Building strings and lists the fast way
The same trap appears when you build a list or a string by repeated concatenation.
result = result + [x] creates a whole new list each round.
import time
def by_concat(n):
result = []
for i in range(n):
result = result + [i] # new list every time: O(n) each
return result
def by_append(n):
result = []
for i in range(n):
result.append(i) # O(1) each
return result
def by_comprehension(n):
return [i for i in range(n)] # same work, less Python overhead
def by_list_range(n):
return list(range(n)) # all the work happens in C
for func in [by_concat, by_append, by_comprehension, by_list_range]:
start = time.perf_counter()
func(20000)
print(f"{func.__name__:>18}: {time.perf_counter() - start:.5f}s")
A factor of over a thousand between the first and the last, for code that produces an
identical result. The first is O(n²); the other three are O(n) with progressively
smaller constants. Note the two different kinds of improvement on display: the jump
from by_concat to by_append is a class change and it
grows with n; the jump from by_append to list(range(n)) is a
constant factor and it does not.
text = text + word inside a loop is quadratic for exactly the same
reason: strings, like lists, get copied. Collect the pieces in a list and use
"".join(pieces) once at the end. That single habit has rescued more
slow scripts than any other tip in this course.
import time
def by_plus(n):
text = ""
for i in range(n):
text += "word " # copies the whole string each round: O(n²)
return text
def by_join(n):
pieces = []
for i in range(n):
pieces.append("word ") # O(1) each
return "".join(pieces) # one linear pass at the end
for n in [25000, 50000, 100000]:
start = time.perf_counter(); by_plus(n)
p = time.perf_counter() - start
start = time.perf_counter(); by_join(n)
j = time.perf_counter() - start
print(f"n={n:>6} += {p:.4f}s join {j:.4f}s ratio {p/j:,.0f}x")
Same shape as §10.4's front-versus-back result, and for the same reason: the ratio
doubles when n doubles, so += is O(n²) and join is O(n). The
fix is one habit — build a list, join once.
10.6When you need a fast front: deque
Sometimes you genuinely need to add and remove at both ends — a queue of jobs, a history of moves. Python has a structure built for it.
from collections import deque
import time
n = 100000
data = list(range(n))
start = time.perf_counter()
while data:
data.pop(0) # O(n) each → O(n²) total
print(f"list.pop(0): {time.perf_counter() - start:.3f}s")
data = deque(range(n))
start = time.perf_counter()
while data:
data.popleft() # O(1) each → O(n) total
print(f"deque.popleft(): {time.perf_counter() - start:.3f}s")Three hundred times faster, and the gap widens with n. You do not need to know how a deque works internally to use it. You do need the habit of asking "is there a structure designed for the operation I keep repeating?" — which is the whole of next week.
A deque is not a free upgrade: reading a middle position, dq[i], is O(n)
on a deque but O(1) on a list. Use a list when you index into the middle a lot; reach
for a deque when your hot operations are at the ends. Right tool, right job —
which needs the cost table for both.
10.7Measuring every operation at once
You have measured a few operations one pair at a time. Here is a single harness that times all the headline operations at a fixed size, so the O(1) rows and the O(n) rows sit next to each other and the difference is impossible to miss. The O(1) operations are far too fast to time once, so we repeat each many times inside the timed region and report the time per operation.
import time
n = 100000
base = list(range(n))
def time_op(setup, op, repeats):
"""Best-of-3 total time for running op() `repeats` times."""
best = None
for _ in range(3):
data = setup()
start = time.perf_counter()
for _ in range(repeats):
op(data)
t = time.perf_counter() - start
if best is None or t < best:
best = t
return best / repeats # seconds per single operation
fresh = lambda: list(range(n))
rows = [
("index read data[n//2]", fresh, lambda d: d[n // 2], 200000),
("append d.append(0)", fresh, lambda d: d.append(0), 200000),
("pop end d.pop()", fresh, lambda d: d.pop(), 200000),
("insert(0) d.insert(0,0)", lambda: list(range(2000)), lambda d: d.insert(0, 0), 2000),
("pop(0) d.pop(0)", lambda: list(range(2000)), lambda d: d.pop(0), 2000),
("membership -1 in d", fresh, lambda d: (-1 in d), 200),
("sort sorted(d)", fresh, lambda d: sorted(d), 50),
]
print(f"{'operation':>26} {'sec / op':>12}")
for label, setup, op, reps in rows:
per = time_op(setup, op, reps)
print(f"{label:>26} {per*1e9:>9,.0f} ns")
Read it as three tiers. The O(1) operations — index, append, pop from the end — all sit
around a few dozen nanoseconds, flat and cheap. The front operations
insert(0) and pop(0) are twenty to fifty times dearer even on
a much shorter list, because each one shifts the whole tail. The O(n) scans
(in a missing value) and the O(n log n) sort are in a different world
entirely — a missing-value membership test on 100 000 items is nearly forty thousand
times slower than an index read. The numbers on your machine will differ; the
tiers will not.
This table pins n and compares operations. It shows you which operations are expensive right now, but a single size cannot reveal a class — for that you need the doubling harness from weeks 5–7, watching how each number responds when n doubles. Use both: the snapshot to spot the costly operation, the doubling run to name its class.
10.8Common mistakes
| Mistake | Why it is wrong |
|---|---|
result = result + [x] in a loop | Copies the whole list each round → O(n²). Use append. |
text += word in a loop | Copies the whole string each round → O(n²). Collect pieces, then "".join(...). |
Using a list as a queue with pop(0) | Each removal shifts the tail → O(n²) over the queue. Use deque.popleft(). |
if x in my_list inside a loop | A hidden O(n) scan under your O(n) loop → O(n²). Use a set for membership (week 11). |
| "append is O(n) because of the resize" | The resize is O(n) but rare; averaged over all appends it is O(1) amortised. |
| "a deque is just a faster list" | Middle indexing on a deque is O(n); it wins only at the ends. |
Slicing to copy inside a loop, data[:] | Each copy is O(n); doing it once per item is another accidental O(n²). |
10.9Try it yourself
Design and run a doubling experiment for each of these, and state the class you
measured: data[n // 2], data.append(1),
data.pop(0), x in data (for a missing x),
data[:] (a full copy).
Hint on method
For the O(1) operations one call is far too fast to time — repeat the operation
100 000 times inside the timed region and divide, keeping the list size fixed
between repeats. For pop(0), rebuild the list before each timing.
Here is a real-shaped piece of slow code. Find both problems and rewrite it.
def unique_reversed(data):
result = []
for item in data:
if item not in result: # problem 1
result = [item] + result # problem 2
return resultSolution
Problem 1: not in result is an O(n) scan inside an O(n) loop → O(n²).
Problem 2: building a new list every round is another O(n) per item. Fixed version,
using a set for the membership test (week 11) and appending at the cheap end:
def unique_reversed(data):
seen = set()
result = []
for item in data:
if item not in seen: # O(1)
seen.add(item)
result.append(item) # O(1)
result.reverse() # O(n), once
return resultFrom O(n²) to O(n). At 100 000 items that is minutes down to milliseconds — measure both to see it.
Build a string of 50 000 words by repeated +=, then by collecting into a
list and calling join. Time both, and report the ratio at 25 000 and
50 000 to show the class difference.
Expected
The += version roughly quadruples in time when you double n (O(n²));
the join version roughly doubles (O(n)). So the ratio between them
itself doubles from 25 000 to 50 000 — see §10.5 for the shape.
Adapt watch_capacity.py from §10.3 to append 1 000 items and record the
length at every capacity jump. Roughly how does each new capacity compare with the
previous one, and how does that explain "O(1) amortised"?
Expected
Each jump lands at roughly (a bit more than) the previous capacity — the list grows by a near-constant factor, so the gaps between jumps get wider as n grows. Because each expensive copy buys room for proportionally more cheap appends, the copying cost spread across all appends averages to a constant: O(1) amortised.
For each scenario, say whether you would store the data in a list or a
deque, and why in one sentence: (a) a to-do queue you always take from
the front and add to the back; (b) a table of scores you look up by row number
thousands of times; (c) a browser history where you add to one end and occasionally
pop the other.
Answers
(a) deque — both hot operations are at the ends, O(1) each; a list would be O(n) per
pop(0). (b) list — random indexing by position is O(1) on a list but
O(n) on a deque. (c) deque — adds and pops at the two ends are what a deque is for.
10.10Self-check
Why is data.pop(0) O(n) while data.pop() is O(1)?
result = result + [i] inside a loop of n items is:
You keep taking jobs from the front of a very long queue. The right change is:
"append is O(1) amortised" means:
To turn a quadratic text += word loop into a linear one, you should:
+=. Collecting pieces and joining once does the whole build in a single linear pass.On a deque, reading a middle position dq[i] is:
10.11Homework
- Create
AA_Week10.ipynband complete Tasks 1–5. - Reproduce §10.4 and §10.5 on your own machine and put all three ratio columns (insert-vs-append, and += vs join) in one table.
- Run
watch_capacity.pyfrom §10.3 up to 1 000 items and note the lengths at which the size jumped; write two sentences linking what you saw to the phrase "O(1) amortised". - Run the §10.7 all-operations harness on your machine and paste the table; write one sentence naming the three tiers you see and which operations fall in each.
- Produce one labelled figure comparing
appendandinsert(0, …)across four sizes. - Write five sentences: you inherit a script that took 20 minutes on 10 000 rows and now must run on 100 000. Based on this week, name the two things you would look for first, and what you would expect the runtime to become if the culprit is quadratic.
10.12Words from this week
| Term | Meaning in plain words |
|---|---|
| contiguous storage | Items kept in one unbroken row of slots — the reason indexing is instant. |
| shifting | Moving every later item to close or open a gap; the hidden cost at the front. |
| amortised O(1) | Usually constant, occasionally expensive, cheap on average over many calls. |
| resize / capacity | The bigger block a list rents when it fills up; the rare O(n) copy behind append. |
| accidentally quadratic | An O(n) operation used once per item, giving O(n²) without any visible nested loop. |
| deque | A structure with cheap adds and removes at both ends, but O(n) middle indexing. |
| join | "".join(pieces) — the linear way to build a big string. |
You learned that searching a list and inserting at its front are both O(n). Week 11 introduces the structures that make lookup O(1): dictionaries and sets.