Backend / Python core / Tricky questions / 47_dict_lookup_methods.md

d[k] vs d.get(k) vs d.setdefault(k, v) vs defaultdict

Updated 4 min read source
On this page8
  1. The four lookup mechanics
  2. When each makes sense
  3. The classic counter pattern
  4. The grouping pattern
  5. Sentinel for “missing vs explicit None”
  6. When to use __missing__ instead
  7. Cheat sheet
  8. Interview angle

d[k] vs d.get(k) vs d.setdefault(k, v) vs defaultdict

The four lookup mechanics

Method Missing key Mutates dict Default semantics
d[k] raises KeyError no calls __missing__ if defined
d.get(k) returns None no second arg is the default; eagerly evaluated
d.get(k, default) returns default no same as above
d.setdefault(k, v) inserts {k: v}, returns v yes (on miss) v is eagerly evaluated even on hit
defaultdict(factory)[k] calls factory(), inserts, returns yes (on miss) factory called lazily only on miss

When each makes sense

d[k] — when missing IS an error

python
def get_config(d, key):
    # KeyError if key not there — caller's fault
    return d[key]

Use when you want loud failure on missing keys. Idiomatic for “I expect this to exist; tell me if it doesn’t.”

d.get(k, default) — when missing is normal

python
timeout = config.get("timeout", 30)

Use for optional values with a fallback. get(k) (no default) returns None — fine for “missing is the same as None” cases.

The trap: get(k, expensive_call()) always evaluates expensive_call, even on hit. Use the if-pattern when default is expensive:

python
val = d[k] if k in d else expensive()

See dict.get(k, default) and falsy values for the falsy-collision trap (get() or default overrides legitimate 0/“”/[]).

d.setdefault(k, v) — insert-if-missing, return current

python
groups = {}
for item in items:
    groups.setdefault(item.kind, []).append(item)

Use for “either initialize this slot or use the existing one, then mutate.” Common for grouping.

The setdefault gotcha: v is evaluated every call, even on hit. Cheap defaults ([], 0, "") are fine; expensive ones aren’t. See setdefault always evaluates the default.

defaultdict(factory) — lazy factory, no eager evaluation

python
from collections import defaultdict
counts = defaultdict(int)
for word in words:
    counts[word] += 1

int() is called only when a key is accessed for the first time. No eager-evaluation tax.

The classic counter pattern

Three ways to count occurrences:

python
# 1. plain dict + setdefault — works, slight overhead from re-evaluating int(0)
counts = {}
for w in words:
    counts.setdefault(w, 0)
    counts[w] += 1

# 2. defaultdict — cleaner, no eager-eval
from collections import defaultdict
counts = defaultdict(int)
for w in words:
    counts[w] += 1

# 3. Counter — the canonical answer
from collections import Counter
counts = Counter(words)

Counter is the canonical idiom — built on defaultdict(int) plus extras (most_common, arithmetic, multiset operations).

The grouping pattern

python
# 1. setdefault — works
groups = {}
for u in users:
    groups.setdefault(u.role, []).append(u)

# 2. defaultdict — cleaner
from collections import defaultdict
groups = defaultdict(list)
for u in users:
    groups[u.role].append(u)

# 3. itertools.groupby — different semantics (input must be sorted)
from itertools import groupby
users.sort(key=lambda u: u.role)
groups = {role: list(items) for role, items in groupby(users, key=lambda u: u.role)}

groupby is a different tool — it forms runs of consecutive equal keys, not full groups. Sort first, or use defaultdict.

Sentinel for “missing vs explicit None”

d.get(k) can’t distinguish “key not present” from “key present with value None”:

python
d = {"a": None}
d.get("a")              # None
d.get("b")              # None

If the distinction matters:

python
MISSING = object()
val = d.get(k, MISSING)
if val is MISSING:
    ...                  # truly absent
elif val is None:
    ...                  # present, set to None

Or use in:

python
if k in d:
    val = d[k]            # might be None
else:
    val = ...             # truly absent

When to use __missing__ instead

If you want d[k] (not .get) to have a custom missing-key behavior, subclass dict and define __missing__:

python
class SafeDict(dict):
    def __missing__(self, key):
        return f"<{key}>"

__missing__ only fires for [] access — not .get(). See __missing__ — and why .get() doesn't trigger it.

Cheat sheet

Use case Best tool
“Must exist; raise if not” d[k]
“Optional with cheap default” d.get(k, default)
“Optional with expensive default” if k in d: d[k] else compute()
“Insert empty container if missing, then mutate” defaultdict(factory)
“Counter” collections.Counter
“Group items by a key” defaultdict(list)
“Lazy fetch + cache, custom miss handler” subclass dict + __missing__
“Distinguish missing from None” MISSING sentinel + get(k, MISSING)

Interview angle 4

  • Q: “What’s the difference between d[k] and d.get(k)?” — [] raises KeyError; .get returns None (or your default) silently.
  • Q: “What’s wrong with d.setdefault(k, expensive())?” — expensive() always evaluates, even on hit. Use defaultdict instead.
  • Follow-up: “When would you reach for defaultdict vs dict.setdefault?” — defaultdict for repeated grouping/counting; setdefault for one-off “insert if missing” with a cheap default.
  • Follow-up: “How do you distinguish ‘key absent’ from ‘key set to None’ with .get?” — sentinel object + is check.

See dict.get(k, default) and falsy values, setdefault always evaluates the default, __missing__ — and why .get() doesn't trigger it, collections — specialized containers.