FastAPI SQLAlchemy Pydantic 500 ResponseValidationError on second request for JSONB datetime fields
FastAPI route with a Pydantic response_model returned 500 ResponseValidationError ('Input should be a valid string' for datetime fields) but only on the SECOND request for the same resource; the first request always worked. The payload is a dict built in Python with datetime values serialized via .isoformat() before being stored in a Postgres JSONB column through SQLAlchemy 2.x. Verified the builder emits plain ISO-8601 strings everywhere (grep confirmed every temporal field goes through .isoformat()). Suspected Pydantic version drift and stale ORM identity-map state first; both were dead ends. The confusing part: the JSON stored in the DB is definitely strings (JSON has no datetime type), yet reading the row back yielded datetime.datetime and datetime.date objects in the payload dict.
The engine was created with a custom deserializer: create_engine(..., json_deserializer=json_loads) where json_loads uses python-rapidjson with datetime_mode=rapidjson.DM_ISO8601. That mode doesn't just serialize datetimes — on loads it revives any string that parses as ISO-8601 back into datetime.datetime/datetime.date objects. So a JSONB round trip is not shape-preserving: {"at": "2026-08-17T15:15:04+00:00", "day": "2026-06-01"} comes back as {'at': datetime.datetime(...), 'day': datetime.date(...)}. Bare YYYY-MM strings are left alone; full dates and datetimes are converted.
First-request-works/second-fails is the tell: the fresh-build path returns the in-memory dict (strings), while subsequent requests read the stored row through the deserializer (datetimes), and the str-typed Pydantic response model rejects them.
Fixes, pick one:
- Normalize at the read boundary — walk the known temporal keys and call
.isoformat()on anydatetime/dateinstance before returning the stored payload. - Type the response-model fields as
datetime/date(Pydantic coerces both strings and objects, and FastAPI re-serializes to ISO).
Option 1 keeps both code paths byte-identical on the wire. Test it by requesting the resource twice in one test — a single-request test only exercises the fresh-build path and passes.
def _restring_temporals(payload: dict) -> dict:
def _iso(v):
return v.isoformat() if isinstance(v, (datetime.datetime, datetime.date)) else v
payload["generated_at"] = _iso(payload.get("generated_at"))
payload["bucket_dates"] = [_iso(d) for d in payload.get("bucket_dates", [])]
return payload