Skip to content

Collections | Python - Wyatt's Notes

Python lists are ordered, mutable sequences of arbitrary objects. They are the most frequently Used built-in container and serve as the default sequence type for most tasks.

CPython implements list as a contiguous array of pointers (specifically, a C array of PyObject*). This is a critical design decision with direct consequences for performance Characteristics.

graph LR
    subgraph "list object on heap"
        ob_refcnt["ob_refcnt"]
        ob_type["ob_type"]
        ob_size["ob_size (length)"]
        allocated["allocated (capacity)"]
        items["items[0] | items[1] | items[2] | ... | items[allocated-1]"]
    end

    subgraph "heap objects"
        obj0["PyObject<br/>"int: 42'"]
        obj1["PyObject<br/>'str: hello'"]
        obj2["PyObject<br/>'list: [1,2]'"]
    end

    items --> obj0
    items --> obj1
    items --> obj2

Each items[i] slot is a pointer to a heap-allocated PyObject. The list itself does not store the Objects inline — it stores references. This means:

  1. A list of three integers occupies three pointer slots (24 bytes on 64-bit) plus three separate heap allocations for the integer objects.
  2. Appending to a list never copies the contained objects. Only pointers are moved.
  3. A single object can appear in multiple lists simultaneously without duplication.

When list.append() runs and the internal array is full, CPython must allocate a new, larger array And copy all existing pointers into it. The growth strategy determines how much larger the new array Is.

graph TD
    A["append(x) called"] --> B{"ob_size == allocated?"}
    B -- No --> C["items[ob_size] = x<br/>ob_size += 1"]
    C --> D["return"]
    B -- Yes --> E["new_allocated = grow(allocated)"]
    E --> F["malloc new array<br/>of size new_allocated"]
    F --> G["memcpy old pointers<br/>to new array"]
    G --> H["free old array"]
    H --> C

The growth formula (from CPython source, Objects/listobject.c) is:

new_allocated = (newsize >> 3) + (newsize < 9 ? 3 : 6)

Roughly, this means the new capacity is approximately newsize + newsize/8 + 6. This is a geometric growth factor of about 1.125x (9/8), which is deliberately smaller than the 2x factor Used by many other languages. The Python developers chose this because:

  • Lists are frequently used for temporary accumulations where the final size is not much larger than the initial size. A 2x factor wastes more memory in these cases.
  • The smaller factor still guarantees amortized O(1) append.

Amortized analysis. Consider n appends to an initially empty list. A resize occurs when the list Hits sizes that trigger reallocation. The total number of pointer copies across all resizes is Bounded by a geometric series that converges to O(n). Therefore, the average cost per append is O(1), even though any single append may cost O(n).

import sys
lst = []
print(sys.getsizeof(lst)) # 56 bytes (empty list)
for i in range(10):
lst.append(i)
print(f"len={len(lst)}, sizeof={sys.getsizeof(lst)}")
## Output pattern on 64-bit CPython:
## len=0, sizeof=56 (empty, 0 slots)
# len=1, sizeof=88 (4 slots allocated)
# len=2, sizeof=88 (4 slots)
# len=3, sizeof=88 (4 slots)
# len=4, sizeof=88 (4 slots, now full)
# len=5, sizeof=120 (8 slots allocated -- grew)
# len=6, sizeof=120 (8 slots)
# len=7, sizeof=120 (8 slots)
# len=8, sizeof=120 (8 slots, now full)
# len=9, sizeof=184 (16 slots allocated -- grew)
# len=10, sizeof=184 (16 slots)
OperationAverage CaseWorst CaseNotes
append(x)O(1) amort.O(n)Resize when capacity reached
pop() (from end)O(1)O(1)
pop(i) (from middle)O(n)O(n)Shifts all elements after index i
insert(i, x)O(n)O(n)Shifts all elements from index i onward
del lst[i]O(n)O(n)Same as pop from middle
lst[i]O(1)O(1)Direct pointer dereference
x in lstO(n)O(n)Linear scan
lst1 + lst2O(n+m)O(n+m)Creates new list, copies all pointers
lst * nO(n*k)O(n*k)
lst.sort()O(n log n)O(n log n)Timsort
len(lst)O(1)O(1)Stored in ob_size
lst.clear()O(1)O(1)Drops references, does not shrink array
lst = [3, 1, 4, 1, 5, 9]
# Mutation
lst.append(2) # [3, 1, 4, 1, 5, 9, 2]
lst.extend([6, 5]) # [3, 1, 4, 1, 5, 9, 2, 6, 5]
lst.insert(0, 99) # [99, 3, 1, 4, 1, 5, 9, 2, 6, 5]
lst.pop() # 5, lst = [99, 3, 1, 4, 1, 5, 9, 2, 6]
lst.pop(0) # 99, lst = [3, 1, 4, 1, 5, 9, 2, 6]
lst.remove(1) # removes first 1, lst = [3, 4, 1, 5, 9, 2, 6]
lst.reverse() # [6, 2, 9, 5, 1, 4, 3]
lst.sort() # [1, 2, 3, 4, 5, 6, 9]
lst.clear() # []
# Non-mutating
lst = [3, 1, 4, 1, 5]
lst.index(4) # 2
lst.count(1) # 2
lst.copy() # shallow copy [3, 1, 4, 1, 5]

Slicing creates a new list containing copies of the pointer slots in the specified range. It Does not copy the referenced objects.

lst = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
# lst[start:stop:step]
lst[2:5] # [2, 3, 4]
lst[:3] # [0, 1, 2]
lst[7:] # [7, 8, 9]
lst[::2] # [0, 2, 4, 6, 8]
lst[::-1] # [9, 8, 7, 6, 5, 4, 3, 2, 1, 0]
lst[1::2] # [1, 3, 5, 7, 9]
lst[-3:] # [7, 8, 9]
lst[-5:-2] # [5, 6, 7]
# Slice assignment (modifies in place)
lst[2:5] = [20, 30, 40] # [0, 1, 20, 30, 40, 5, 6, 7, 8, 9]
lst[1:1] = [10, 11] # insert without replacing: [0, 10, 11, 1, 20, ...]
del lst[2:4] # delete slice

Slicing has O(k) time complexity where k is the size of the slice. The step parameter is handled Internally by a loop that strides through the source array, so lst[::2] is O(n/2) = O(n).

List comprehensions are both more concise and faster than equivalent for loops because they run at C speed inside the CPython interpreter loop.

# List comprehension
squares = [x**2 for x in range(10)]
# Equivalent for loop (slower -- Python bytecode per iteration)
squares = []
for x in range(10):
squares.append(x**2)
# With condition
even_squares = [x**2 for x in range(10) if x % 2 == 0]
# Nested (flattening a matrix)
matrix = [[1, 2, 3], [4, 5, 6], [7, 8, 9]]
flat = [x for row in matrix for x in row] # [1, 2, 3, 4, 5, 6, 7, 8, 9]

Python’s built-in collections are the workhorses of data manipulation. Lists are dynamic arrays: fast to append, slow to insert in the middle. Tuples are immutable lists: once created, they cannot change, which makes them hashable and useful as dictionary keys. Sets are unordered collections of unique elements, perfect for membership testing and deduplication. The collections module adds specialised containers: defaultdict eliminates key-existence checks, Counter tallies occurrences, and deque provides O(1) operations at both ends.