Hands-on setdefault vs if-not-in vs defaultdict lab: real ops/s for list-append and counter default insert patterns, measured on Linux localhost (lab).
4 min read
Insert-or-default on a dict: is setdefault, an if k not in d, or collections.defaultdict fastest? This lab times list-append groupby and scalar counter patterns on Linux localhost under miss-heavy / mixed / hit-heavy key mixes.
Related links:
Lab honesty (1 Oct 2026 IST): Python 3.13.5. N=200,000 key ops/arm. List arms seed 5 000 existing keys. Affiliates: 0. Complements Counter tally with the “ensure bucket then mutate” pattern.
Verdict up front (mixed list-append): defaultdict ~5.11M/s ≈ setdefault ~5.08M (~1.01×); hit-heavy defaultdict ~1.08× setdefault. Scalar hit: d.get/defaultdict(int) beat setdefault++= by ~1.36× / ~1.32×. Pick defaultdict for ongoing groupby; use d[k]=d.get(k,0)+1 for counters.
| Arm | Pattern |
|---|---|
| setdefault | d.setdefault(k, []).append(1) |
| if-not-in | if k not in d: d[k]=[] then append |
| try/except | append, catch KeyError |
| defaultdict(list) | d[k].append(1) |
| get-or | lst=d.get(k); ... |
| scalar | setdefault/get/defaultdict(int) counters |
Hit rates: miss 10%, mixed 50%, hit 90%.
Script: lab-evidence/72-setdefault-vs-defaultdict/results/run_lab.py.
| Arm | miss-heavy | mixed | hit-heavy |
|---|---|---|---|
| setdefault | 3.17M | 5.08M | 13.58M |
| if-not-in | 2.82M | 4.81M | 11.84M |
| defaultdict | 2.81M | 5.11M | 14.73M |
| get-or | 2.96M | 5.16M | 13.55M |
| try/except | 2.30M | 4.17M | 13.21M |
| Arm | all-miss | hit-heavy |
|---|---|---|
| get-add | 7.90M | 20.50M |
| setdefault then += | 6.78M | 15.11M |
| if-not-in | 6.40M | 15.65M |
| defaultdict(int) | 5.88M | 19.90M |
Note: setdefault + += does an extra lookup after insert — d[k]=d.get(k,0)+1 avoids that.
get/defaultdict(int), not setdefault+mutate for ints.defaultdict changes missing-key behavior for the lifetime of the object — fine inside a function, surprising if you return it to callers who expect KeyError. setdefault keeps a plain dict and only inserts when you ask. For library boundaries, prefer setdefault (or build with defaultdict then dict(d)). For tight internal grouping loops, defaultdict’s __getitem__ path is the ergonomic default and often the speed default on hit-heavy mixes.
setdefault for int counters — double lookup with +=.dict — convert or document.| Need | Prefer |
|---|---|
| Ongoing list/set buckets | defaultdict(list) / setdefault |
| Int/float counters | d[k]=d.get(k,0)+1 or defaultdict(int) / Counter |
| One-shot ensure | setdefault |
| Plain dict required | setdefault / if-not-in |
Across miss / mixed / hit, no single helper wins every cell by a wide margin on list buckets — the story is “same ballpark, pick clarity,” except try/except on cold keys and setdefault++= for ints. Profile the hit rate you actually see in production logs before rewriting idioms for a 5% microbench delta.
Evidence: /workspace/lab-evidence/72-setdefault-vs-defaultdict/results/.
defaultdict for buckets; get-add for counters. On this box mixed list-append setdefault and defaultdict tied near ~5.1M/s, hit-heavy favored defaultdict by ~1.08×, and scalar get-add led setdefault by ~1.36×. Match the helper to whether the default is a container or a number.
Lab evidence
Lab 1 Oct 2026 IST. Python 3.13.5; N=200000. Mixed list-append: defaultdict 5.11M ≈ setdefault 5.08M (~1.01x); hit-heavy defaultdict ~1.08x. Scalar hit: get-add/defaultdict beat setdefault (~1.36x/~1.32x). Affiliates: 0. Evidence: lab-evidence/72-setdefault-vs-defaultdict/.
Hands-on collections.Counter vs dict tally lab: real ops/s for Counter.update, dict get-add, and defaultdict(int) token counting on Linux localhost (lab).
Lab1 Oct 20264 min read
Hands-on fnmatch.filter vs re.compile name-list filtering measured on Linux localhost.
Lab1 Oct 20264 min read
Hands-on tarfile vs zipfile create+extract lab: real MB/s on a mixed small-file fixture (uncompressed tar vs zip), measured on Linux localhost for SREs.
Lab1 Oct 20264 min read