Polyglot
Languages Python data structures
Python § data-structures

Data structures

Python’s built-in data structures — list, tuple, dict, set, frozenset, plus bytes and bytearray — cover the conventional needs of most programs. Beyond these, the collections module provides deque, Counter, OrderedDict, defaultdict, and namedtuple; the heapq module provides priority-queue operations on lists; the bisect module provides binary search; the array module provides packed numeric arrays. The standard library is broad enough that most non-trivial programs use only the built-ins; non-standard alternatives (NumPy arrays, pandas DataFrames) are conventional in numeric and data-science contexts.

This page covers the principal data structures, the choice among them, and the standard-library extensions.

list

A mutable, ordered, heterogeneous sequence:

xs = [1, 2, 3]
xs.append(4)
xs.insert(0, 0)
xs.pop()                  # remove and return last
xs.pop(0)                 # remove and return first

xs[0]                      # 1
xs[-1]                     # 3
xs[1:3]                    # slicing

xs + [5, 6]                # concatenation (new list)
xs * 2                     # repetition

len(xs)
3 in xs
xs.index(3)                # position; raises if absent
xs.count(2)                # number of occurrences
xs.reverse()               # in place
xs.sort()                  # in place
sorted(xs)                 # new list, sorted

list(range(10))            # [0, 1, 2, ..., 9]
list("hello")              # ['h', 'e', 'l', 'l', 'o']

Performance:

OperationComplexity
xs[i]O(1)
xs.append(x)Amortised O(1)
xs.insert(0, x)O(n)
xs.pop()O(1)
xs.pop(0)O(n)
xs.remove(value)O(n)
x in xsO(n)
xs.sort()O(n log n)

list is the conventional default sequence; it is implemented as a dynamic array. For frequent insertion at the front, collections.deque (treated below).

tuple

An immutable ordered sequence:

t = (1, 2, 3)
t = 1, 2, 3              # parens optional in many contexts
empty = ()
single = (1,)            # trailing comma required for one-element tuple

t[0]                      # 1
t[1:]                     # (2, 3)

# Unpacking:
a, b, c = (1, 2, 3)
a, *rest = (1, 2, 3, 4)   # a=1, rest=[2, 3, 4]

Tuples are conventionally for fixed-shape records (the type’s structure is stable). The principal uses:

  • Multiple return values: def divmod(a, b): return a // b, a % b.
  • Dictionary keys: tuples are hashable (if their contents are).
  • Heterogeneous fixed-shape data: ("alice", 30, "alice@example.com").

For named fixed-shape data, dataclass or typing.NamedTuple is preferable:

from typing import NamedTuple

class Point(NamedTuple):
    x: float
    y: float

p = Point(3.0, 4.0)
p.x                # 3.0
p.y                # 4.0
p[0]               # 3.0; also indexable

NamedTuple and dataclass admit named access; the latter is more capable.

dict

A mutable key-value mapping:

ages = {"alice": 30, "bob": 28}
ages["alice"]              # 30
ages["carol"] = 35
del ages["bob"]
ages.get("dave", 0)         # default if absent
"alice" in ages             # True

# Iteration:
for key in ages:                # iterates keys
    print(key)
for value in ages.values():
    print(value)
for key, value in ages.items():
    print(f"{key}: {value}")

# Comprehension:
squares = {n: n ** 2 for n in range(10)}

# Merging (3.9+):
merged = a | b              # b's values override a's
inplace = a | b              # same; produces a new dict
a |= b                       # in-place

# Unpacking:
combined = {**a, **b}        # equivalent for the merge

Insertion order is preserved (since Python 3.7). Keys must be hashable; values may be anything.

Performance:

OperationComplexity
d[k]Average O(1)
k in dAverage O(1)
d[k] = vAverage O(1)
del d[k]Average O(1)

dict is the principal Python container; nearly every program uses it heavily.

Common patterns

# Increment a counter:
counts = {}
for word in words:
    counts[word] = counts.get(word, 0) + 1

# Conditional initialisation:
groups = {}
for item in items:
    if item.category not in groups:
        groups[item.category] = []
    groups[item.category].append(item)

# Cleaner alternatives in `collections`:
from collections import Counter, defaultdict

counts = Counter(words)                # Counter
groups = defaultdict(list)
for item in items:
    groups[item.category].append(item)

The collections module’s Counter and defaultdict (treated below) replace the patterns above with shorter, clearer equivalents.

set and frozenset

Unordered collections of unique hashable elements:

s = {1, 2, 3}
s.add(4)
s.remove(2)
2 in s                       # False

a = {1, 2, 3}
b = {2, 3, 4}
a | b                         # {1, 2, 3, 4} (union)
a & b                         # {2, 3} (intersection)
a - b                         # {1} (difference)
a ^ b                         # {1, 4} (symmetric difference)

a <= b                        # subset
a < b                         # proper subset
a.isdisjoint(b)               # True if no overlap

# Comprehension:
unique_lengths = {len(s) for s in strings}

# Empty set (NOT {}, which is dict):
empty = set()

The frozenset is the immutable variant — admits use as a dict key or as a member of another set:

points = {frozenset({1, 2}), frozenset({3, 4})}

Both require their elements to be hashable.

bytes and bytearray

Sequences of bytes:

b = b"hello"               # bytes; immutable
b[0]                        # 104 (the byte value, an int)

ba = bytearray(b"hello")    # mutable
ba[0] = ord("H")
bytes(ba)                   # b"Hello"

# Conversion to/from str:
"hello".encode("utf-8")
b"hello".decode("utf-8")

# Construction:
bytes([1, 2, 3, 4])         # b"\x01\x02\x03\x04"
bytes(range(5))              # b"\x00\x01\x02\x03\x04"

bytes is for byte data — files, network protocols, binary formats. bytearray is the mutable variant, useful when in-place modification matters.

range

A lazy integer sequence:

r = range(10)              # 0, 1, ..., 9
r = range(1, 11)           # 1, 2, ..., 10
r = range(0, 100, 5)       # 0, 5, 10, ..., 95
r = range(10, 0, -1)       # 10, 9, ..., 1

list(r)                    # materialise as list
sum(range(1, 101))         # 5050
1 in range(10)             # True

range produces values on demand; the entire sequence is not stored. The conventional iteration source for numeric for-loops.

collections module

The standard library extends the built-ins with several specialised types:

deque

A double-ended queue with O(1) operations at both ends:

from collections import deque

dq = deque([1, 2, 3])
dq.append(4)               # right; O(1)
dq.appendleft(0)           # left; O(1)
dq.pop()                   # O(1) right
dq.popleft()               # O(1) left

dq.rotate(2)               # rotate right by 2
dq.extend([5, 6])
dq.extendleft([0, -1])     # adds in reverse order

# Bounded:
recent = deque(maxlen=10)  # keeps only the last 10

deque is the conventional choice for queues, stacks (pop is O(1) for the right; for the left, use popleft), and bounded recent-history buffers.

Counter

A dict subclass for counting hashable objects:

from collections import Counter

words = ["apple", "banana", "apple", "cherry", "apple", "banana"]
counts = Counter(words)
# Counter({'apple': 3, 'banana': 2, 'cherry': 1})

counts.most_common(2)        # [('apple', 3), ('banana', 2)]
counts["apple"]              # 3
counts["unknown"]            # 0 (no KeyError)

counts.update(["apple", "date"])     # add to counts
counts["apple"]              # 4

# Arithmetic between counters:
c1 = Counter(a=3, b=1)
c2 = Counter(a=1, b=2)
c1 + c2                      # Counter(a=4, b=3)
c1 - c2                      # Counter(a=2)
c1 & c2                      # Counter(a=1, b=1) — minimum
c1 | c2                      # Counter(a=3, b=2) — maximum

The conventional choice for “count occurrences of each”.

defaultdict

A dict that supplies a default for missing keys:

from collections import defaultdict

groups = defaultdict(list)
for item in items:
    groups[item.category].append(item)
# missing keys are created with [] on first access

string_lengths = defaultdict(int)
for s in strings:
    string_lengths[s] += 1
# missing keys default to 0

The constructor takes a factory (a callable returning the default); each missing key is created with the factory’s result.

OrderedDict

A dict that preserves insertion order. As of Python 3.7, regular dict also preserves insertion order, so OrderedDict is rarely needed. It does have one advantage: equality is order-sensitive (OrderedDict([("a", 1), ("b", 2)]) != OrderedDict([("b", 2), ("a", 1)])).

from collections import OrderedDict

od = OrderedDict()
od["a"] = 1
od["b"] = 2
od.move_to_end("a")        # move "a" to the end
od.popitem(last=False)      # pop the first item (FIFO)

namedtuple

Tuple subclass with named fields:

from collections import namedtuple

Point = namedtuple("Point", ["x", "y"])
p = Point(3, 4)
p.x                          # 3
p.y                          # 4
p[0]                         # 3 (also indexable)

p2 = p._replace(y=0)         # immutable, but produces a new tuple

namedtuple is the older form; typing.NamedTuple (with type hints) and @dataclass are conventionally preferable in modern code.

ChainMap

A view that combines multiple dicts:

from collections import ChainMap

defaults = {"color": "red", "size": 10}
overrides = {"size": 20}

config = ChainMap(overrides, defaults)
config["color"]              # "red" (from defaults)
config["size"]               # 20 (from overrides)

Lookups walk through the maps in order. The mechanism is occasionally useful for hierarchical configuration (CLI > env > defaults).

heapq

The heapq module provides priority-queue operations on lists:

import heapq

xs = [3, 1, 4, 1, 5, 9, 2, 6]
heapq.heapify(xs)            # turn into a min-heap; in place
heapq.heappush(xs, 0)         # add
smallest = heapq.heappop(xs)  # remove and return smallest

# n smallest / largest:
heapq.nsmallest(3, xs)        # [0, 1, 1]
heapq.nlargest(3, xs)         # [9, 6, 5]

# With key:
heapq.nsmallest(3, items, key=lambda x: x.priority)

The heap is a list ordered as a binary heap; heapq operations maintain the invariant. The module is the conventional Python priority-queue implementation; for thread-safe priority queues, queue.PriorityQueue.

bisect

The bisect module provides binary search on sorted sequences:

import bisect

xs = [1, 3, 5, 7, 9]
i = bisect.bisect_left(xs, 4)     # 2 (where to insert 4)
bisect.insort_left(xs, 4)          # insert in sorted order
# xs is now [1, 3, 4, 5, 7, 9]

# Find the first element >= value:
i = bisect.bisect_left(sorted_xs, value)
if i < len(sorted_xs) and sorted_xs[i] == value:
    # found

The module is the conventional Python binary-search facility.

array

Packed homogeneous numeric arrays:

import array

a = array.array("i", [1, 2, 3, 4])    # signed ints
a = array.array("d", [1.0, 2.0, 3.0])  # doubles

The array module is more memory-efficient than list for large numeric arrays. For substantial numeric work, NumPy is the conventional choice.

enum

Named enumerated constants:

from enum import Enum, auto

class Color(Enum):
    RED = auto()
    GREEN = auto()
    BLUE = auto()

c = Color.RED
c.name                       # "RED"
c.value                       # 1

for color in Color:
    print(color)

The Enum is the conventional Python idiom for closed sets of named constants. The auto() admits automatic value assignment.

For flag-style enums (bitwise combinations), IntFlag:

from enum import IntFlag

class Permission(IntFlag):
    READ = 1
    WRITE = 2
    EXECUTE = 4

p = Permission.READ | Permission.WRITE
Permission.READ in p          # True

Choice of container

The conventional decision tree:

NeedContainer
Default sequencelist
Fixed-shape datatuple (or dataclass)
Lookup by keydict
Set membershipset
Frozen setfrozenset
CountingCounter
Default values for missing keysdefaultdict
Both ends fastdeque
Bounded recentdeque(maxlen=N)
Priority queueheapq
Sorted sequence with binary searchlist + bisect
Numeric arraysarray (small), NumPy (large)
Named valuesEnum
Combined dictsChainMap

The default for “I need a collection” is list for sequences, dict for keyed lookup, set for membership.

Iterator-friendly patterns

Counting occurrences

from collections import Counter

counts = Counter(words)
top_three = counts.most_common(3)

Grouping

from collections import defaultdict

groups = defaultdict(list)
for item in items:
    groups[item.category].append(item)

Or with itertools.groupby (requires sorted input):

from itertools import groupby

for category, group in groupby(sorted(items, key=lambda x: x.category),
                                 key=lambda x: x.category):
    print(category, list(group))

Building a lookup

by_id = {item.id: item for item in items}

A dict comprehension produces an id-indexed map.

Removing duplicates while preserving order

def unique(items):
    seen = set()
    result = []
    for item in items:
        if item not in seen:
            seen.add(item)
            result.append(item)
    return result

# Or, in modern Python (3.7+, with hashable items):
def unique(items):
    return list(dict.fromkeys(items))

The dict.fromkeys form admits “deduplicate while preserving order” in one expression.

Partitioning

def partition(predicate, items):
    yes, no = [], []
    for item in items:
        (yes if predicate(item) else no).append(item)
    return yes, no

Python does not have a built-in partition for lists; the manual implementation is straightforward.

Index of items

indexed = list(enumerate(items))   # [(0, a), (1, b), (2, c)]

A note on persistence

Python’s built-in containers are not persistent — modifications mutate the data in place. The conventional contrast is:

  • Immutable: tuple, frozenset, bytes. New values are produced; the old is unchanged.
  • Mutable: list, set, dict, bytearray. Operations modify in place.

For functional-style updates (build a new structure without modifying the old), the conventional Python approach is to copy:

new_dict = {**old_dict, "new_key": "new_value"}
new_list = old_list + [new_item]

For substantial persistent structures, third-party libraries (pyrsistent) provide them; the conventional Python style accepts mutation in most cases.