🐛 Fix support for form values with empty strings interpreted as missing (None if that's the default), for compatibility with HTML forms
Project: tiangolo/fastapi | Language: Python | Concepts: error-handling, api-design, testing-strategy | Difficulty: mid
Link: https://github.com/fastapi/fastapi/pull/13537
The problem
When submitting HTML forms, empty fields are sent as empty strings (""). In FastAPI, if an endpoint expects an optional parameter (e.g., Optional[int] = None) via a form, submitting an empty string should be interpreted as "no value" (None). A regression caused FastAPI to incorrectly treat empty strings as actual values, leading to validation errors instead of using the default (None).
Why it's non-trivial
A naive fix might only check for None values, but in HTML forms, an empty string is a valid (and common) way for browsers to represent a missing value. The internal logic used None to mean both "not present" and "default is None," which created ambiguity. Fixing this requires carefully tracking which fields have already been processed, rather than just relying on the values dictionary, to avoid accidentally overwriting or misinterpreting empty form values.
The approach
Change in dependency processing to track processed fields explicitly:
- for key, value in received_body.items():
- if key not in values:
- values[key] = value
+ field_aliases = {field.alias for field in body_fields}
+ for key, value in received_body.items():
+ if key not in field_aliases:
+ values[key] = value
- What this does:
The old logic would add any key from the received body to the values dict if it wasn't already present. The new logic introducesfield_aliases, a set of all field names expected by the model. Now, only keys not in this set are added. This prevents re-processing fields that were already handled, even if their value wasNone(e.g., because the form sent an empty string). This avoids the bug where an empty string could overwrite aNonedefault.
New tests to verify correct handling of empty form values:
+def test_form_default_url_encoded():
+ response = client.post("/urlencoded", data={"age": ""})
+ assert response.status_code == 200
+ assert response.text == "null"
+
+
+def test_form_default_multi_part():
+ response = client.post("/multipart", data={"age": ""})
+ assert response.status_code == 200
+ assert response.json() == {"file": None, "age": None}
- What this does:
These tests send form data with an empty string for an optional integer field. The expected behavior is that FastAPI returnsNone(serialized asnull), not an error or an empty string. This directly checks that the bug is fixed.
From the review thread
PidgeyBE: This looks odd to me.
If you pass a string to an integer field, I would expect a4xxerror code.
schneebuzz: This is a form POST and empty numeric inputs would be submitted as empty strings. IMO it's reasonable that they are accepted and treated like no value (
None/null) when the field is an optional integer.
Takeaways
- When handling form data, treat empty strings as missing values for optional fields to match browser behavior and user expectations.
- Avoid using a single sentinel value (like
None) to mean both "not present" and "default is None"; explicitly track which fields have been processed. - Always add regression tests for subtle input handling bugs, especially around form serialization and deserialization.