5 KiB
| title | aliases | tags | sources | created | updated | |||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| Pydantic Model Passed Where Dict Expected — Silent .get() Failure |
|
|
|
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 returnsNone— Pydantic v1 models don't have a.get()method; calling it doesn't raiseAttributeError, it silently falls back to Python's defaultNone(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 asdictand 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.fieldattribute 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.
Related Concepts
- wiki/concepts/zustand-async-hydration — another silent failure pattern from the same barclays-banner-builder session
- wiki/concepts/pydantic-v2-alias-id-gotcha — other Pydantic serialization pitfalls
Sources
- daily/2026-04-28.md — barclays-banner-builder:
refine_variant_copytask accepted dict but was called with BannerCopy Pydantic object;.get()returned None silently; caused hang in AI refinement pipeline