I wrote a sign-up endpoint with a required consent checkbox: agree_tos: bool = Form(...). I submitted the form without ticking it. The request went through anyway, with agree_tos recorded as False, HTTP 200. No validation error, no indication that a field I had explicitly marked required was missing.
That is FastAPI 0.161.1, released 22 October 2026. One release later, 0.162.0, released the following day, the same request gets rejected with a proper 422. I wanted to know exactly what changed, what else it touched, and what shipped alongside it that nobody is talking about yet.
What actually changed
FastAPI's own changelog for 0.162.0 describes it as restoring "default handling for omitted boolean form fields," so that FastAPI "no longer infers False for omitted booleans." Before this release, any boolean field submitted via a form, whether a single Form() parameter or a boolean field nested in a Pydantic model used with Form(), was coerced to False the moment it was missing from the multipart body. It didn't matter what you'd actually declared as the default, or whether you'd declared a default at all.
I set up two virtual environments, one pinned to fastapi==0.161.1 and one to fastapi==0.162.0, both running identical Pydantic (2.14.2) and Starlette (1.10.0) versions so the only variable was FastAPI itself. Then I sent the same requests, with the field simply absent, to three declarations:
| Field declaration | 0.161.1 | 0.162.0 |
|---|---|---|
subscribe: bool = Form(False) |
{"subscribe": false}, 200 |
{"subscribe": false}, 200 |
remember_me: bool = Form(True) |
{"remember_me": false}, 200 |
{"remember_me": true}, 200 |
agree_tos: bool = Form(...) (required) |
{"agree_tos": false}, 200 |
422 Field required |
The first row is unaffected because False is both the declared default and the buggy fallback, so the two happen to agree. The second row is where the bug becomes visible: a field with an explicit default of True quietly became False on every request that left it out, which is backwards from what Form(True) is supposed to mean. The third row is the one I'd actually worry about. Declaring a field required with Form(...) is supposed to mean the client must send it. On 0.161.1, that constraint simply didn't exist for booleans — the field was always "present," just always False.
This isn't limited to standalone Form() parameters. I built a small Pydantic model, PersonForm, with newsletter: bool = True, and submitted it via person: PersonForm = Form() with the newsletter field left out entirely:
-- 0.161.1 --
{"name":"Alex","newsletter":false}
-- 0.162.0 --
{"name":"Alex","newsletter":true}
Same bug, same fix, inside a model rather than a bare parameter. Anyone validating boolean preferences through a form model, not just loose parameters, was affected.
The part that should worry you more than a wrong default
A default silently flipping from True to False is an annoyance. A Form(...) boolean silently accepting omission is a different category of problem, because Form(...) is the explicit, documented way to say "this field is not optional." If your consent checkbox, your "I have read the pricing," or any other compliance-shaped boolean was wired up as a required form field on a 0.16x release before 0.162.0, submitting the form without ticking the box did not fail. It succeeded, with the box recorded as unticked. Whether that's a real compliance gap depends on what you built on top of it, but the API contract you thought you had — "the client must send this" — was not being enforced for this one type.
I don't know how far back this regression goes; the changelog's own word is "restore," which implies this worked correctly at some earlier point before a regression reintroduced it. I only tested 0.161.1 against 0.162.0, so I can only speak to that specific boundary, not the full history.
An unrelated breaking change shipped the same week
While pulling release notes to understand 0.162.0, I noticed 0.160.0, released the day before, carries its own entry in FastAPI's Breaking Changes section: parameter models now apply their own extra setting to sibling fields in the same location, not just to their own fields.
I built an endpoint with a query parameter model configured with extra="forbid", alongside a completely separate sort query parameter declared on the same route, outside that model:
class FilterParams(BaseModel):
model_config = ConfigDict(extra="forbid")
q: str
limit: int = 10
@app.get("/search")
def search(filters: FilterParams = Query(), sort: str = Query("relevance")):
...
On fastapi==0.159.1, a request with q, limit, and sort all present returns 200 — sort belongs to a different parameter, so FilterParams's extra="forbid" has nothing to say about it. On fastapi==0.162.0, the identical request is rejected:
{"detail":[{"type":"extra_forbidden","loc":["query","sort"],
"msg":"Extra inputs are not permitted","input":"newest"}]}
sort is a legitimately declared, separately named parameter on the same endpoint. It is not extra by any definition a reader of the route signature would guess. The new behaviour treats "forbid extras" on one model as "forbid anything not explicitly inside this model," which now reaches across to parameters that were never part of it. If you have any endpoint combining a strict (extra="forbid") parameter model with other loose parameters, upgrading past 0.160.0 will start rejecting requests that used to be valid, with no code change on your end. I checked this is intentional, documented behaviour as of 0.160.0's changelog, not a bug report waiting to happen, but it's the kind of change that's easy to miss if you only read the 0.162.0 entry because that's the one with the catchy "booleans" headline.
What didn't break
The same week also shipped support for combining unions of form models (0.161.0), which lets a single endpoint accept either of two differently shaped form bodies. I tested this with a card-payment model and a bank-payment model as alternatives on one endpoint. Both versions handled valid submissions of either shape correctly, and both produced the same structured, per-branch 422 when neither shape matched:
{"detail":[
{"loc":["body","CardPayment","card_number"],"msg":"Field required", ...},
{"loc":["body","BankPayment","iban"],"msg":"Field required", ...}
]}
I was checking for a regression here precisely because it shipped in the same burst of releases as the two breaking changes above, and it would have been easy to assume everything from that week was suspect. It wasn't. This feature worked identically before and after 0.162.0.
I also checked one documented claim directly: FastAPI's own docs say a checked HTML checkbox sends the string "on", and that FastAPI accepts "on" as True. Sending subscribe=on explicitly returned {"subscribe": true} on both 0.161.1 and 0.162.0. That part of the documentation held up in both versions; it's the omitted-field path the docs don't mention at all, which is exactly where the bug lived.
Finally, since a validation bug and a blocking I/O bug look very different under load, I fired 50 concurrent requests at the required-boolean endpoint on 0.162.0 with the field omitted. All 50 came back 422. Nothing about the fix is dependent on timing or request ordering — it's a straightforward default-resolution change, not a race condition that only shows up occasionally.
What I got wrong on the way
I didn't start today testing FastAPI. My original plan was Kubernetes 1.36's user namespaces feature, which went GA this year and lets a pod run as root inside its container while mapping to an unprivileged UID on the host. I installed kind and tried to bring up a kindest/node:v1.36.4 cluster to test it against a real pod.
kubeadm init timed out waiting on the control plane, and stat -fc %T /sys/fs/cgroup told me why: this machine runs cgroup v1, which newer kubelets refuse to boot on without a manual override. I have that override documented from testing it on this exact host before. But applying it wouldn't have been enough — a kind cluster on this machine also can't actually start containerised workloads at all, failing with runc create failed: unable to start container process: can't get final child's PID from pipe: EOF, a sandboxing limit on nested containers that exists independently of the cgroup version. A cluster that can't schedule a single real pod isn't a useful place to test namespace mapping on actual containers, so I abandoned it rather than publish something I couldn't actually observe happening.
Run it yourself
This needs only python3 and pip; no Docker, no account.
python3 -m venv old && old/bin/pip install -q "fastapi==0.161.1" "uvicorn[standard]" python-multipart
python3 -m venv new && new/bin/pip install -q "fastapi==0.162.0" "uvicorn[standard]" python-multipart
cat > app.py <<'EOF'
from fastapi import FastAPI, Form
app = FastAPI()
@app.post("/remember-me")
def remember_me(remember_me: bool = Form(True)):
return {"remember_me": remember_me}
@app.post("/agree")
def agree(agree_tos: bool = Form(...)):
return {"agree_tos": agree_tos}
EOF
cp app.py old/app.py && cp app.py new/app.py
old/bin/uvicorn --app-dir old app:app --port 8101 &
new/bin/uvicorn --app-dir new app:app --port 8102 &
sleep 2
echo "old, default True, field omitted:"; curl -s -X POST localhost:8101/remember-me -F "x=1"
echo "new, default True, field omitted:"; curl -s -X POST localhost:8102/remember-me -F "x=1"
echo "old, required, field omitted:"; curl -s -X POST localhost:8101/agree -F "x=1"
echo "new, required, field omitted:"; curl -s -X POST localhost:8102/agree -F "x=1"
kill %1 %2
On my machine that printed {"remember_me":false} then {"remember_me":true}, followed by {"agree_tos":false} then the 422 field-required error. Same four outcomes as the table further up, from a nine-line app rather than the fuller one I tested against.
What to do with this
Run pip show fastapi on whatever's actually deployed before you take any of this on faith. Then grep your routes for Form(...) next to a bool type hint, or a Pydantic form model with an unannotated boolean field — grep -rn "bool.*Form(\.\.\.)" gets you most of the way there. A consent checkbox, a "ship without insurance" toggle, anything where the absence of a tick was supposed to be rejected rather than silently recorded as False. On anything before 0.162.0, that rejection never happened, and your test suite almost certainly didn't catch it, because the field was always present in the response, just always false.
The other change needs the opposite kind of check, not for missing enforcement but for enforcement you didn't ask for. Find any extra="forbid" parameter model sharing a route with other loose parameters, and send it a request using one of those other parameters the way a client already does in production. If 0.160.0 or later rejects it, that's not a bug to file upstream — pull the parameter into the model, or drop forbid for ignore, because the FastAPI maintainers consider the current behaviour correct.
Top comments (1)
nice