Python's dict.get(key) or default Trap: The Config That Silently Ignores Every Zero, False, and Empty String

2026-07-25

You're reviewing a config loader. Callers pass in a dictionary and any missing keys fall back to sensible defaults. The idiom looks tidy — one line per option, no verbose if key in config ceremony. Ops files a ticket: "I explicitly disabled SSL verification for a debugging session and the service is still verifying. Also my max_retries=0 keeps retrying three times." You stare at the code and everything looks right.

def create_user(config, name):
    max_retries  = config.get("max_retries")  or 3
    timeout_ms   = config.get("timeout_ms")   or 5000
    verify_ssl   = config.get("verify_ssl")   or True
    log_prefix   = config.get("log_prefix")   or "[user]"

    print(f"Creating {name!r} with:")
    print(f"  max_retries = {max_retries}")
    print(f"  timeout_ms  = {timeout_ms}")
    print(f"  verify_ssl  = {verify_ssl}")
    print(f"  log_prefix  = {log_prefix}")

# Ops disables retries and SSL verification for a one-off run:
create_user(
    {"max_retries": 0, "timeout_ms": 0, "verify_ssl": False, "log_prefix": ""},
    "alice",
)
# Creating 'alice' with:
#   max_retries = 3
#   timeout_ms  = 5000
#   verify_ssl  = True
#   log_prefix  = [user]

Every single value the caller passed got overwritten by the default. The "one-off debugging" call is running with production SSL verification and retry behavior. Worse, this bug can lurk for years because nobody writes max_retries=0 until the day they do.

The Bug

The idiom config.get(key) or default conflates two different meanings of "missing":

So the moment a caller passes a legitimately-falsy value, or discards it and installs the default. This is almost always a bug, and it's a spectacularly dangerous one for boolean flags: verify_ssl=False silently becomes True. A security-critical opt-out becomes an opt-in.

Ironically, dict.get already has a defaults mechanism built in — its second argument, which only fires on actual absence:

def create_user(config, name):
    max_retries  = config.get("max_retries", 3)
    timeout_ms   = config.get("timeout_ms",  5000)
    verify_ssl   = config.get("verify_ssl",  True)
    log_prefix   = config.get("log_prefix",  "[user]")
    ...

Now max_retries=0 stays 0, verify_ssl=False stays False, and log_prefix="" stays empty. The distinction between "the user didn't say" and "the user said this" is preserved.

Why does this trap survive code review? Because x or default reads like English ("x, or if not, the default") and works fine most of the time — for strings that are usually non-empty, numbers that are usually positive, objects that are usually not None. The pattern accumulates in the codebase until someone passes the one value it can't distinguish from absence.

The rule: reserve or for genuine "any-falsy-means-missing" semantics (e.g., "if the user's display name is empty, show their email"). For "if the key is absent, use this default," use dict.get(key, default), dict.setdefault, or an explicit if key in config. And for booleans, or is almost never what you want — False or True is True, always.

Key Takeaway: x or default substitutes on any falsy value, not just None — for "use default when key is absent," pass the default to dict.get as its second argument, or the caller's 0, False, and "" will silently vanish.

All newsletters