What is a Python list underneath?

A dynamic array of references — one contiguous block of pointers, over-allocated so there is usually spare room. Indexing is O(1) because the address is computed. append is amortised O(1) because it usually writes into a spare slot. insert(0, x) is O(n) because everything has to shift.

Overview

A dynamic array of pointers

A Python list is a contiguous C array of pointers to objects, plus a length and a capacity.

[ptr, ptr, ptr, ptr, —, —, —]  length 4, capacity 7

Two facts follow, and between them they explain every performance characteristic a list has.

It is contiguous, so lst[i] is a single offset calculation — O(1) — and iteration reads sequential memory, which CPU prefetchers handle very well.

It holds pointers, not values. A list can contain objects of different types, because every slot is the same size regardless of what it points at. That flexibility costs memory: 8 bytes per pointer plus the object itself.

It is emphatically not a linked list, despite the name. There are no per-element next pointers, and indexed access is constant time.

Lists & arraysConceptualEasy

Step through it

What to watch

  • The spare slots are the reason append is normally free.
  • The reallocation copies everything — once per doubling, not per append.
  • The last frame is the same cost as pop(0), in the other direction.

Say this out loud

"It's a dynamic array of pointers, over-allocated. Indexing is O(1) arithmetic, append is amortised O(1) because growth doubles, and anything that touches the front is O(n) because the rest has to shift."

What is a Python list underneath?

What is a Python list actually implemented as, and why does that make append O(1) but insert(0, x) O(n)?

Run it

The over-allocation made visible with sys.getsizeof, then the three operations timed against each other so the O(1) and the O(n) are numbers rather than claims.

1Python
Output

Why append is O(1) amortised

The list holds spare capacity beyond its length. append writes into that space and increments the length — a couple of operations.

When capacity runs out, CPython allocates a larger block, copies every pointer across, and frees the old one. The growth is roughly new = old + old/8 + constant, so about 12.5% over-allocation rather than doubling.

That resize is O(n). Spread across the many appends the new capacity accommodates, the average per append is constant — which is what amortised O(1) means.

OperationComplexityWhy
lst[i]O(1)Offset into a contiguous block
appendO(1) amortisedWrites into spare capacity
pop()O(1)Removes from the end
insert(0, x)O(n)Shifts every pointer right
pop(0)O(n)Shifts every pointer left
x in lstO(n)Linear scan
len(lst)O(1)Stored, not counted
lst[a:b]O(b−a)Allocates and copies

The two O(n) rows at the front are the practical trap: pop(0) in a loop is O(n²) and reads as ordinary code. collections.deque gives O(1) at both ends.

Memory, and NumPy's advantage

A list of a million small integers holds a million 8-byte pointers, and each integer is a separate heap object with a header — 28 bytes each in CPython.

2Python
Output

The total including the integer objects is far more — roughly 40MB.

A NumPy array of the same data stores raw 64-bit values contiguously: 8MB total, with no per-element objects. That is the memory argument, and there is a speed argument too: operations run in C over the whole array, and the data is cache-friendly because the values themselves are adjacent rather than scattered pointers.

 Python listNumPy array
StoresPointers to objectsRaw values
Mixed typesYesNo — one dtype
1M integers~40MB8MB
Element accessFastFast
Whole-array arithmeticLoop in PythonVectorised in C
AppendO(1) amortisedExpensive — reallocates

The last row matters: NumPy arrays are for fixed-size numeric data, and building one by appending defeats the purpose.

An array of pointers, not of objects

The block holds references, all the same size, which is why one list can hold an int, a string and another list at once. It is also why sys.getsizeof on a list of a million integers is far smaller than the integers themselves — the list only stores the pointers.

Because the references are contiguous and equally sized, element i lives at a computable address. That is the whole reason indexing is O(1) and a linked list is not.

Why append is amortised O(1)

CPython allocates more room than the list currently needs. Most appends write into a spare slot: one store, no allocation. When the block fills, a larger one is allocated and everything is copied — O(n), but only on that one append.

Because the growth is geometric, the copies are rare enough that the cost spread over n appends is constant each. That is what "amortised" means, and it is worth saying the word: any individual append can be the expensive one, which matters if you care about latency rather than throughput.

Why the front is expensive

insert(0, x) and pop(0) both have to move every other element one slot, so both are O(n). Doing either in a loop is O(n²), and that is the single most common accidental quadratic in Python — usually written as a queue.

collections.deque is the fix: a doubly linked list of blocks, O(1) at both ends. It gives up O(1) random access in exchange, which is almost always the right trade when you only touch the ends.

The aliasing consequences

Because a list holds references, copying has two levels — and this is where the bugs are.

3Python
Output

Shallow copying is enough for immutable contents and not for nested mutable ones:

4Python
Output

And the famous one:

5Python
Output

[x] * n repeats the reference n times. Harmless for immutable values, a bug for mutable ones. Understanding that a list holds pointers is exactly what makes this predictable rather than mysterious.

Choosing another container

NeedUseWhy
Add/remove at both endsdequeO(1) at the front
Membership testingsetO(1) instead of O(n)
Key-value lookupdictO(1)
Fixed recordtupleImmutable, hashable, less memory
Numeric arraysnumpy.ndarrayContiguous values, vectorised
Homogeneous numbers, no mathsarray.arrayRaw values, standard library
Sorted order maintainedbisect.insort or sortedcontainersOrdered operations

array.array is the under-known one: it stores raw values like NumPy but is in the standard library and supports append efficiently. Useful when you want the memory saving without a NumPy dependency.

Questions people ask

Is a Python list a linked list? No — a contiguous dynamic array of pointers, with O(1) indexing.

Why is insert(0, x) slow? Every existing pointer shifts one position. Use deque.appendleft.

By how much does it grow? Roughly 12.5% over-allocation, not doubling — a compromise between memory and copy frequency.

Why can a list hold mixed types? Because it stores pointers, and every pointer is the same size whatever it points at.

Does del lst[i] free memory? It removes the reference and shifts the remainder. The object is freed when nothing references it.

When should I use NumPy? Homogeneous numeric data, especially with arithmetic over the whole array — roughly five times less memory and far faster.

Recap in one screen

  • A contiguous array of pointers with spare capacity, plus a stored length.
  • Contiguity gives O(1) indexing and cache-friendly iteration; pointers allow mixed types at a memory cost.
  • Append is O(1) amortised because of over-allocation; operations at the front are O(n) because everything shifts.
  • Holding references is why b = a shares, copy() is shallow, and [[0]*3]*3 repeats one row.
  • For homogeneous numeric data, NumPy or array.array stores raw values and uses a fraction of the memory.

How the code works

The over-allocation made visible with sys.getsizeof, then the three operations timed against each other so the O(1) and the O(n) are numbers rather than claims.

How the code works

  1. sys.getsizeof(lst)The size jumps in steps, not per item. Those jumps are the reallocations; between them every append is a single store into memory that was already reserved.
  2. append versus insert(0, x)The same number of operations, wildly different timings. insert(0, x) moves every existing element one slot, so n of them is O(n²).
  3. deque.appendleftA doubly linked list of blocks, so both ends are O(1). It is the fix for anything that behaves like a queue — and it gives up O(1) indexing in return.
  4. data[len(data) // 2]Identical timings on ten items and ten million. Address arithmetic does not care how much is stored, which is precisely what a linked list cannot offer.

Change one thing

  • Double N. The append timing doubles; the insert(0, x) timing roughly quadruples.
  • Compare sys.getsizeof for a list of 1,000 integers against array.array('i', range(1000)). The array stores values, not pointers.

Where this runs

Real CPython, compiled to WebAssembly and running on your own machine — nothing is uploaded. The first run takes a few seconds while the interpreter downloads; after that it is immediate. Need more room, or want to paste your own attempt? Use the Python compiler.

Check yourself

0 of 3

Answer without scrolling back up.

  1. A Python list is implemented as:

  2. Why is append 'amortised' O(1) rather than simply O(1)?

  3. insert(0, x) is O(n) because:

Cheat sheet

What is a Python list underneath?

A dynamic array of references — one contiguous block of pointers, over-allocated so there is usually spare room. Indexing is O(1) because the address is computed. append is amortised O(1) because it usually writes into a spare slot. insert(0, x) is O(n) because everything has to shift.

INTERVIEW · vizlearn.in/interview/what-is-a-python-list-underneath.html

About the author

Ashish Jangra builds and maintains VizLearn. Every module here is written and the visualisation behind it hand-built, so the numbers in a readout come from the same code that draws the picture. Corrections are genuinely welcome and get priority over everything else — if a page states something wrong, or an animation misrepresents what the algorithm does, get in touch.