Python's dict.setdefault Eager Evaluation Trap: The Cache That Opens a Fresh Connection on Every Hit

2026-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.

The Bug

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.

The Fix

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.

Key Takeaway: 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.

All newsletters