dict.setdefault Eager Evaluation Trap: The Cache That Opens a Fresh Connection on Every Hit2026-09-08
This helper is supposed to lazily create a database-backed session for a user and cache it, so subsequent calls with the same user_id reuse the same session without touching the database. It passes its unit tests. In production, the DB team files a ticket: connection pool exhausted, and the offending service is the one that "has a cache."
import time
def create_session(user_id):
# Expensive: opens a DB connection, runs auth queries, allocates state.
print(f"[db] opening connection for {user_id}")
return {"user": user_id, "started": time.time()}
def get_session(user_id, cache):
return cache.setdefault(user_id, create_session(user_id))
if __name__ == "__main__":
cache = {}
for _ in range(3):
get_session("alice", cache)
# Expected: one "[db] opening connection" line.
# Actual: three.
dict.setdefault(key, default) is not lazy. It is a regular method call, and Python evaluates all arguments before the method sees any of them. That means create_session(user_id) runs on every single call — whether or not the key already exists. setdefault merely decides which value to return and whether to store; it can't decide whether to invoke the argument you already invoked.
People reach for setdefault thinking it behaves like the ternary cache[key] if key in cache else expensive(), but that intuition is imported from languages with lazy defaults (Kotlin's getOrPut, Rust's Entry::or_insert_with). Python has no such short-circuit for method arguments.
The tests missed it because they asserted on the returned dict, not on side effects. The cache does return the same object on every call — after all, the first-inserted value wins — but every miss and every hit still pays the full cost of creating a fresh session that immediately gets thrown away. On a lightly-loaded dev box, the wasted connections are invisible. Under production concurrency, they saturate the pool.
The performance hit is bad enough; the correctness hit is worse when create_session has side effects — inserting an audit row, incrementing a rate-limit counter, minting a token. Every "cache hit" now emits an audit row for a session that will never be used.
Guard the expensive call yourself, or use a construct that actually is lazy:
# Option 1: explicit check. Boring, obvious, correct.
def get_session(user_id, cache):
if user_id not in cache:
cache[user_id] = create_session(user_id)
return cache[user_id]
# Option 2: defaultdict, if the factory takes no args.
from collections import defaultdict
sessions = defaultdict(lambda: create_session_for_current_context())
# Option 3: functools.lru_cache when the "cache" IS the memoization.
from functools import lru_cache
@lru_cache(maxsize=None)
def get_session(user_id):
return create_session(user_id)
The same trap applies to dict.get(key, default): the default is always evaluated. It doesn't matter as often there because get's default is usually a literal like 0 or None, but the moment someone writes d.get(k, fetch_from_api()), the bug is back.
Rule of thumb: if the "default" is anything more expensive than a literal or an already-constructed object, setdefault and get are the wrong tools. Use if key not in d, use defaultdict with a factory, or use a real memoization decorator.
dict.setdefault(k, expensive()) evaluates expensive() on every call — Python has no lazy arguments, so what looks like a cache is a cache plus a fresh side effect on every hit.
