Preamble

Interview folklore claims tuples beat lists for read-only indexed access. The intuition: immutability and a fixed shape might optimize better. That is sometimes measurable, but the margin is usually small on CPython compared with algorithm choice, I/O, and C extensions. This note walks through how to test the claim honestly, what the interpreter is doing, and where tuples still earn their keep—including concurrency, where the story is mostly about safety and invariants, not raw throughput.


A small benchmark harness you can trust a little

Use timeit (or perf_counter in a tight loop with many repetitions) so setup is not counted. Vary n and the access pattern (sequential index, strided index, random index via precomputed keys). Always warm up once before recording.

import random
import timeit
from statistics import mean, stdev


def bench(label, stmt, setup, repeat=7, number=50):
    times = timeit.repeat(stmt, setup=setup, repeat=repeat, number=number)
    per_loop = [t / number for t in times]
    print(
        f"{label}: mean={mean(per_loop)*1e6:.2f}µs "
        f"stdev={stdev(per_loop)*1e6:.2f}µs (per inner loop)"
    )


def main():
    n = 100_000
    trials = 500_000
    # Precompute random indices so randomness cost is not inside the timed loop.
    setup_common = f"""
n = {n}
trials = {trials}
import random
rng = random.Random(0)
idxs = [rng.randrange(n) for _ in range(trials)]
"""
    for kind in ("tuple", "list"):
        setup = setup_common + f"data = {kind}(range(n))\n"
        bench(
            f"sequential sum ({kind})",
            "s = 0\n"
            "for i in range(trials):\n"
            "    s += data[i % n]\n",
            setup,
        )
        bench(
            f"random index sum ({kind})",
            "s = 0\n"
            "for i in idxs:\n"
            "    s += data[i]\n",
            setup,
        )


if __name__ == "__main__":
    main()

Scale n from modest (10⁴) to large (10⁶) and trials so each run takes tens of milliseconds—short enough to avoid thermal throttle drift, long enough to average timer noise. Compare same element type (range produces **int**s in both branches); mixing heterogeneous tuples changes pointer chasing and cache behavior.


What CPython is actually doing

Both tuple and list are sequences of object pointers in CPython: an array of PyObject* plus metadata. Indexed read (BINARY_SUBSCR / LOAD_SUBSCR) resolves to similar paths for homogeneous numeric content. Tuples do not magically pack **int**s next to each other; each slot is still a boxed Python object unless you leave CPython for array, numpy, memoryview, etc.

Where tuple can show an edge:

  • Construction and teardown in hot inner loops (no overallocation growth strategy like list).
  • One-shot literals the bytecode compiler can BUILD_TUPLE efficiently.
  • Smaller peak memory for fixed-size sequences because list may keep extra capacity after append patterns (irrelevant if you built with list(range(n)) once).

Where list is fine or better:

  • You mutate ends often (append, pop); list is the right tool.
  • You need homogeneous numeric vectors at speed: use array('i'), numpy, or a C extension—not tuple vs list.

Net: for read-heavy indexing of plain Python objects, expect single-digit percent differences at most on many machines—often noise.


Memory: getsizeof tells a biased story

sys.getsizeof counts container overhead, not the objects they reference. A tuple and list of the **same int**s point at the same boxed integers (small interned ints for small values). Differences you see are mostly struct header and list overallocated slots. For fair comparison, build both once, hold them, and profile RSS externally if you care about real footprint.


Concurrency: neither is a “faster mutex”

CPython threads share the GIL for Python bytecode; CPU-bound pure-Python work does not scale with threads. Reads of immutable tuples do not require you to lock the tuple to prevent shape changes—but reference counting and object access still run under interpreter rules you do not micro-manage.

Practical differences:

  • Shared list: another thread can append, pop, or assign L[i] = x while you iterate or index, producing race bugs or RuntimeError during iteration. You synchronize with locks or copy the sequence.
  • Shared tuple: no in-place mutation; readers see a stable sequence of references. Contents can still be mutable objects (tuple of **dict**s): those inner objects need their own discipline.

multiprocessing: both pickle; immutability does not grant magical IPC speed. asyncio: neither structure fixes event-loop overhead; choose based on API shape.

So: tuple is not “more concurrent” in the throughput sense; it is often better for read-mostly shared data because it enforces immutability of the sequence itself.


Method notes

Control CPU frequency noise where possible; warm caches; repeat trials. Micro-benchmarks on laptops lie; trends still inform whether differences are noise or structure. Run on the Python version you ship; 3.11+ opcode costs differ from 3.9.


Observation

On CPython, indexed-read gaps are often a few percent—visible in tight synthetic loops, rarely why a real app feels slow. Immutability documents intent: callers know you will not append mid-flight.


When tuples are genuinely handy (performance-adjacent)

  • Fixed records returned from hot functions: less allocation churn than mutable containers you might accidentally grow.
  • dict keys and set members when elements are hashable (lists are not).
  • Unpacking and pattern matching ergonomics that reduce buggy index magic.
  • Stable snapshots: pass a tuple into threads or tasks when you want “this sequence will not be reshaped” without copying a list defensively.

Raw index speed alone is a weak reason; semantics, hashing, and allocation patterns are the durable wins.


Conclusion

Choose tuples for invariants and correctness; chase tuple-vs-list micro-wins only after profiling shows Python-level indexing dominates. For numeric throughput, leave tuple/list behind and use appropriate buffers or libraries. Java 11 Tooling for Someone Coming from Python opens the Java tooling arc for Pythonistas.