obsidian/wiki/concepts/pydantic-model-dict-interface.md
2026-04-28 22:26:04 +01:00

5 KiB

title aliases tags sources created updated
Pydantic Model Passed Where Dict Expected — Silent .get() Failure
pydantic-dict-interface
pydantic-get-none
pydantic-vs-dict
pydantic
python
fastapi
celery
debugging
type-safety
silent-failure
daily/2026-04-28.md
2026-04-28 2026-04-28

Pydantic Model Passed Where Dict Expected — Silent .get() Failure

When a function written to accept a dict is called with a Pydantic model, calling .get("key") on the model returns None (since .get() is not a standard Pydantic method) instead of raising an AttributeError or TypeError. The function continues executing with None values — producing silent wrong behavior, hangs, or downstream failures rather than a clear error.

Key Points

  • .get() on a Pydantic model returns None — Pydantic v1 models don't have a .get() method; calling it doesn't raise AttributeError, it silently falls back to Python's default None (or Pydantic's own method resolution)
  • Symptom: Task or function appears to run but produces wrong output, hangs, or sends empty/null data to downstream systems — no exception raised at the call site
  • Detection: Add assert isinstance(data, dict) or type-hint the parameter as dict and run mypy; or check the call site to see what type is actually being passed
  • Fix option A: Convert at the call site — task.delay(banner_copy.model_dump())
  • Fix option B: Update the function to accept Pydantic model — def refine_variant_copy(copy: BannerCopy) and use .field attribute access

Details

How the Bug Manifests

In the barclays-banner-builder, a Celery task refine_variant_copy was written expecting a dict:

# tasks.py — written expecting dict
def refine_variant_copy(copy: dict, ...):
    headline = copy.get("headline")   # ← works if copy is dict
    body_text = copy.get("body_text") # ← returns None if copy is Pydantic model
    # Function continues with headline=None, body_text=None
    # AI call gets empty inputs → returns garbage or hangs waiting

The call site passed a BannerCopy Pydantic model:

# caller
banner_copy = BannerCopy(headline="Buy Now", body_text="Great offer")
refine_variant_copy.delay(banner_copy, ...)  # ← wrong type passed

Pydantic v1 models do implement __getitem__ (for subscript access like copy["key"]), but not the dict .get() method with its default-return semantics. When Python resolves copy.get("headline") on a Pydantic model, it finds no such method and returns None rather than raising — this is because BaseModel does not define .get(), so Python's attribute lookup falls back to None in some configurations, or Pydantic provides a stub.

The result: every .get() call returns None. The function keeps running with all-None values, silently producing wrong output.

Detection

Runtime check at function entry:

def refine_variant_copy(copy, ...):
    if not isinstance(copy, dict):
        raise TypeError(f"Expected dict, got {type(copy).__name__}")
    ...

Static analysis (mypy):

def refine_variant_copy(copy: dict, ...) -> str:
    ...
# mypy will flag: Argument 1 has incompatible type "BannerCopy"; expected "Dict[str, Any]"

Grep for the pattern:

grep -n "\.get(" tasks.py
# Then check each call site to see what type is passed

Fix

Option A — Convert at call site (minimal change):

# Convert Pydantic model to dict before passing to task
refine_variant_copy.delay(banner_copy.model_dump(), prompt, job_id)
# Pydantic v1: banner_copy.dict()
# Pydantic v2: banner_copy.model_dump()

Option B — Update function signature to accept Pydantic model (cleaner):

from app.models import BannerCopy

def refine_variant_copy(copy: BannerCopy, ...) -> str:
    headline = copy.headline        # attribute access, not .get()
    body_text = copy.body_text
    ...

Option B is preferable when the function is an internal API — it makes the type contract explicit and mypy can catch misuse. Option A is safer when the function is a Celery task that may receive serialized data from a queue (where dicts are the natural representation after JSON deserialization).

General Rule

Before writing a function that calls .get() on its arguments, decide explicitly: does this function take a dict or a typed model? Document it with a type annotation. If the codebase mixes both, add isinstance guards or use .model_dump() at the boundary where Pydantic models enter dict-oriented code.

Sources

  • daily/2026-04-28.md — barclays-banner-builder: refine_variant_copy task accepted dict but was called with BannerCopy Pydantic object; .get() returned None silently; caused hang in AI refinement pipeline