Plate 09
set vs frozenset vs list: Membership Lookup Lab
A hands-on localhost lab measuring set, frozenset, list, and dict-key membership across N, with the real crossover point.
Aditya Challa5 min read
Intro — what this post promises
Is x in my_list fine until “it gets big”? Folklore says switch to a set “eventually.” This lab measures membership (x in container) for set, frozenset, list, and dict keys across N on Linux localhost, and reports the crossover.
Related links:
- sorted vs heapq vs bisect localhost lab
- pathlib vs os.path localhost lab
- dataclass vs slots vs dict localhost lab
- json vs orjson vs msgpack localhost lab
- python re vs str methods localhost lab
- tempfile NamedTemporaryFile localhost lab
- uuid4 vs secrets token localhost lab
- Why your average latency graph is lying (p50 / p95 / p99)
Lab honesty (1 Oct 2026 IST): Python 3.13.5. String keys; ~50% hit / 50% miss probes. Affiliates: 0. Not a perfect-hash / bloom-filter study.
Verdict up front: set is already ~2.1× list at N=8, ~20× at N=128, and ~3917× at N=32 768. frozenset ≈ set; dict keys ≈ set. The “wait until N is huge” story does not survive this curve.
What we measure
| Container | Lookup meaning | Expected shape |
|---|---|---|
set | hash membership | ~O(1) avg |
frozenset | immutable hash membership | ~O(1) avg |
list | linear scan (in) | O(n) |
dict | key in dict (hash) | ~O(1) avg |
Same probe sequence for every arm. ops/s = probes ÷ p50 batch wall. We do not time construction — containers are built once before the timed loop.
Lab topology
Why scale probe counts? A fair ns/op comparison still holds (ops ÷ wall). Leaving list at 200k probes for N=32k would dominate wall time without teaching more. Script: lab-evidence/48-set-vs-list-membership/results/run_lab.py.
Lead table — membership ops/s (p50)
| N | set | frozenset | list | dict | set÷list |
|---|---|---|---|---|---|
| 8 | 31,726,668 | 30,909,202 | 15,166,042 | 30,800,989 | 2.1× |
| 32 | 34,366,446 | 34,423,473 | 6,271,959 | 30,002,384 | 5.5× |
| 128 | 35,662,359 | 35,597,095 | 1,803,328 | 28,125,716 | 20× |
| 512 | 33,380,734 | 33,532,494 | 457,976 | 27,592,098 | 73× |
| 2,048 | 31,780,688 | 31,756,466 | 124,072 | 27,032,287 | 256× |
| 8,192 | 29,927,397 | 30,345,881 | 27,691 | 26,472,394 | 1081× |
| 32,768 | 29,406,145 | 29,822,706 | 7,508 | 26,457,825 | 3917× |
Crossover flags from this run: set ≥ 2× list at N=8; ≥ 10× at N=128.
ns/op for list at N=32 768: ~133196 ns vs set ~34 ns.
Reading the curve
- Hash containers stay ~30–38 ns/op across this N range (string hash + table probe). Set at N=8 was ~31.5 ns; at N=32k ~34.0 ns.
- List falls roughly with N — classic linear scan; at N=32 already ~5.5× behind set; at N=512 ~73×; at N=8 192 ~1081×.
- frozenset tracks set within a few percent (immutable, same hash machinery) — use it when you need a hashable set-of-sets or a constant allowlist.
- dict
inis the same hash story as set — slightly slower here (~0.85× set at N=2048) from value payload / table layout, not a different asymptotic.
If you already have a dict of payloads, key in d is enough — do not build a parallel set “for speed” unless profiling says so.
Methodology notes
Probes are a fixed sequence shared across arms so hit-rate and key distribution cannot favor one container. Warmup runs discard first-touch noise. ops/s uses p50 batch wall, not sum-of-repeats. Miss keys are distinct strings (m*) so we exercise both sides of the branch — a hits-only bench would understate list cost when early elements match.
Pitfalls
- Building a set once, probing once. Construction cost is real; this lab times lookup only after containers exist. Hot-path “build set every request” needs a different bench.
- Integer keys vs strings. Hash cost differs; ratios stay the same shape.
- Sorted list + bisect. Different tool — see the ordered-insert lab; not
inon a plain list. - Tiny allowlists (N=2–3). Absolute list time may still be fine; the 2× crossover already at N=8 is the warning for growing codepaths.
- Identity vs equality. These are equality membership checks on strings — not
is.
When to pick what
| Need | Prefer |
|---|---|
| Repeated membership, N ≳ 8 | set / frozenset / dict keys |
| Tiny fixed literals, clarity | list/tuple OK until measured otherwise |
| Immutable constant set | frozenset (hashable as element too) |
| Map payload by key | dict — membership comes free |
Reproduce
Evidence: /workspace/lab-evidence/48-set-vs-list-membership/results/.
Closing
List membership does not stay “fine.” On this box set is already ~2.1× at N=8, ~20× at N=128, and ~3917× at N=32k. frozenset and dict keys ride with set. Convert allowlists that grow — don’t wait for a production cliff.
Lab evidence
What I found running this
Lab 1 Oct 2026 IST. Python 3.13.5. Membership used 50/50 hit/miss string probes across N=8, 32, 128, 512, 2048, 8192, and 32768. Measured p50 ops/s after warmup: at N=8 set 31.73M/s vs list 15.17M/s; at N=128 set 35.66M/s vs list 1.80M/s; at N=2048 set 31.78M/s vs list 124.1k/s; at N=32768 set 29.41M/s vs list 7.5k/s. Surprised by the 2x crossover already at N=8; frozenset tracked set and dict keys stayed close. Affiliates: 0.
Related links
Plate 12
islice vs list Slice Windows: Localhost Lab
Hands-on itertools.islice vs list slice window lab: real ops/s taking ranges from sequences, measured on Linux localhost in this hands-on lab for SREs.
Observability & SRE · 1 Oct 2026
Plate 07
heapq.merge vs sorted(chain): Localhost Lab
Hands-on heapq.merge vs sorted(chain) multi-way merge: real records/s on pre-sorted lists, measured on Linux localhost today in this hands-on lab for SREs.
Observability & SRE · 1 Oct 2026
Plate 88
mmap Write vs pwrite Region: Localhost Lab
Hands-on mmap MAP_SHARED write+msync vs pwrite region update: real MB/s with durability labels, measured on Linux localhost in this hands-on lab for SREs.
Observability & SRE · 1 Oct 2026