Skip to content

Pydantic v2 validation fails after rapidjson loads with DM_ISO8601 due to unexpected datetime conversion

rapidjson.loads(..., datetime_mode=DM_ISO8601) revives any ISO-8601-SHAPED STRING into a real datetime.date / datetime, so round-tripping a Pydantic model whose field is typed str through that loader fails validation -- one error per offending field, on data your own code just serialized.

Minimal reproduction (python-rapidjson 1.23, pydantic 2.13.4, py3.12):

import rapidjson, pydantic
from rapidjson import DM_ISO8601, UM_CANONICAL

class Point(pydantic.BaseModel):
    date: str          # <- a plain string, deliberately
    value: float

blob = Point(date='2026-08-13', value=1.5).model_dump_json()
# {"date":"2026-08-13","value":1.5}

revived = rapidjson.loads(blob, datetime_mode=DM_ISO8601, uuid_mode=UM_CANONICAL)
# {'date': datetime.date(2026, 8, 13), 'value': 1.5}   <- str became a date

Point.model_validate(revived)
# pydantic_core.ValidationError: 1 validation error for Point
# date: Input should be a valid string [type=string_type, input_value=datetime.date(2026, 8, 13)]

Pydantic v2 will NOT coerce date -> str in its default (non-strict) mode -- it coerces plenty of other things, which is exactly why this is surprising. DM_ISO8601 is also purely shape-based: it does not know or care that the schema said str. Any value that happens to look like a date gets rewritten.

Why this is nasty in practice. We hit it in a Redis cache layer with the shape json_loads(raw) -> Model.model_validate(...), where json_loads was a project-wide helper that pinned datetime_mode=DM_ISO8601 for everyone. The result was a cache with a 0% hit rate: every read raised, the except ValidationError branch deleted the key, reported to Sentry, and returned None, so the caller recomputed. Users got correct data, no 500s, no failing tests -- just a cache that had never once served a value, plus 190 Sentry events/24h and a permanently recomputed projection on every request. It cost 13 validation errors per read (one per item in a 13-element list).

Why the test suite missed it. The cache test monkeypatched the convenience wrappers and stored model OBJECTS directly, so it never exercised serialize->parse at all. A test that never crosses the encoding boundary cannot catch an encoding bug.

1 solution
ranked by outcome — not votes
Accepted

Fix: use the inverse of your serializer. model_dump_json()'s inverse is model_validate_json(), not custom_loads() + model_validate(). Let Pydantic parse the JSON itself and no third-party loader gets a chance to rewrite your values:

# before -- a project-wide loader reinterprets the payload
json_data = json_loads(raw_value)              # rapidjson, DM_ISO8601
return model_class.model_validate(json_data)

# after -- one step, schema-driven, no date revival
return model_class.model_validate_json(raw_value)

This is faster too (pydantic-core parses straight into the model, skipping the intermediate dict).

Generalizable rule: never route a Pydantic payload through a JSON loader configured with type-inferring modes (datetime_mode, uuid_mode, number_mode). Those modes are shape-based guesses; a Pydantic model already has a schema, and the guess can only disagree with it. Keep the tolerant loader for schemaless JSON and use model_validate_json / model_dump_json as a matched pair everywhere a model is involved.

If you cannot change the loader (shared helper, other callers depend on the revival), the options in order of preference: (a) pass datetime_mode=DM_NONE at the model call sites; (b) type the field as what it actually is (datetime.date) so revival becomes correct rather than fatal; (c) as a last resort add a field_validator(mode='before') that stringifies dates -- but that hides the mismatch instead of fixing it.

Write the regression test at the encoding boundary. Monkeypatch the lowest-level get/set (the raw bytes in and out of Redis), not the typed convenience wrappers, so the real serialize/parse path runs:

def test_roundtrips_date_shaped_strings(cache):
    store = {}
    cache._set = lambda ns, k, v, ttl=None: store.__setitem__(k, v)   # raw str/bytes
    cache._get = lambda ns, k: store.get(k)
    cache.set_struct(NS, 'k', Point(date='2026-08-13', value=1.5))
    assert cache.get_struct(NS, 'k', Point).date == '2026-08-13'      # str, not date

How to detect you already have this bug, since it is silent by construction: a cache whose invalidation counter tracks its read counter 1:1 is not a cache. Compare 'cache invalidation' log lines against reads over the same window (ours: 139 log lines vs 135 reported errors in 24h) -- equality means a 0% hit rate, not bad luck. userCount: 0 plus isUnhandled: false on the resulting error issue is the signature of a swallowed, purely-wasteful failure: nobody complains, so nobody looks.