🐛 Fix using Json[list[str]] type (issue #10997)
Project: tiangolo/fastapi | Language: Python | Concepts: error-handling, api-design, testing-strategy | Difficulty: mid
Link: https://github.com/fastapi/fastapi/pull/14616
The problem
When using FastAPI's Form() dependency with a parameter typed as Json[list[str]], FastAPI incorrectly treated the field as a sequence and called getlist() on the form data. This wrapped the JSON string in a list (e.g., ['["abc", "def"]']), causing Pydantic's Json parser to fail with a TypeError: JSON input should be string, bytes or bytearray. The bug prevented using Form() with Json[T] where T is a sequence type (like list, set, or tuple).
Why it's non-trivial
A naive fix might simply change how all sequence types are handled, but this would break legitimate uses where getlist() is needed (e.g., for plain list[str] form fields). The fix must distinguish between a sequence field and a field that expects a single JSON string representing a sequence, to avoid breaking other behaviors.
The approach
The core of the fix is to detect when a field is a Json type and adjust how the form value is extracted:
def _is_json_field(field: ModelField) -> bool:
return any(type(item) is Json for item in field.field_info.metadata)
Annotation:
This helper checks if the field is a Pydantic Json field by inspecting its metadata. If any metadata item is of type Json, it returns True. This allows the code to distinguish between a plain sequence and a JSON-encoded sequence.
def _get_multidict_value(
field: ModelField, values: Mapping[str, Any], alias: Union[str, None] = None
) -> Any:
alias = alias or get_validation_alias(field)
if (
(not _is_json_field(field))
and is_sequence_field(field)
and isinstance(values, (ImmutableMultiDict, Headers))
):
value = values.getlist(alias)
else:
value = values.get(alias, None)
return value
Annotation:
This function previously always used getlist() for sequence fields, which broke Json[list[str]]. The fix adds a check: if the field is a Json type, it skips getlist() and uses get() instead, ensuring the value remains a JSON string for parsing.
Comprehensive tests were added to verify the fix across different parameter types:
@app.post("/form-json-list")
def form_json_list(items: Annotated[Json[list[str]], Form()]) -> list[str]:
return items
def test_form_json_list():
response = client.post(
"/form-json-list", data={"items": json.dumps(["abc", "def"])}
)
assert response.status_code == 200, response.text
assert response.json() == ["abc", "def"]
Annotation:
This test ensures that a form field with type Json[list[str]] is parsed correctly after the fix.
Similar tests were added for Query, Header, and Cookie parameters to confirm consistent behavior.
From the review thread
We missed one thing - we didn't check the openapi schema. And it's seems to be not good..
With the code example from the description of this PR, the related part of schema is:
"tags": { "type": "string", "contentMediaType": "application/json", "contentSchema": { "items": { "type": "string" }, "type": "array" }, "title": "Tags" }And it's rendered by Swagger UI as file upload:
[screenshot]
And, this is how Pydantic represents it.. So, not sure we can easily fix this..
The only way I can see is to use
WithJsonSchema:from typing import Annotated from fastapi import FastAPI, Form from fastapi.datastructures import UploadFile from pydantic import Json, WithJsonSchema app = FastAPI() @app.post("/upload") def upload( file: UploadFile, tags: Annotated[Json[list[str]], Form(), WithJsonSchema({"type": "string"})], ): return {"file": file.filename, "tags": tags}Now, Swagger UI renders it as just string input:
[screenshot]
But if you attempt to send data as
["a", "b"], it will fail. Swagger UI automatically pre-processes strings that look like arrays and it converts our input to justa,b:[screenshot]
To finally pass the value, you need to put it inside the double quotes and escape the quotes inside:
"[\"a\", \"b\"]"[screenshot]
I mean for Query you use
Json[list[str]], but for CookieJson[dict[str, str]](listvsdict).What if you declare cookie endpoint as:
@app.get("/cookie-json-list") def cookie_json_list( items: Annotated[Json[list[str]], Cookie() ], ) -> list[str]: return items?
I think this should also fail on
masterbranch and be fixed by this PR
I thought the same, but when I tested on master, it passed 😶
tests/test_form_json_type.py::test_form_json_list FAILED tests/test_form_json_type.py::test_query_json_list FAILED tests/test_form_json_type.py::test_header_json_list FAILED tests/test_form_json_type.py::test_cookie_json_list PASSEDLooks like cookies don't go through the getlist() path. cookie values are plain dict, not ImmutableMultiDict or Headers, so they skip that.
Takeaways
- When handling form or query data, always distinguish between fields that expect a single JSON string (e.g.,
Json[list[str]]) and fields that expect multiple values (e.g.,list[str]). - Use type and metadata inspection to drive conditional logic in framework internals, especially when supporting advanced typing features.
- Comprehensive, cross-parameter tests (Form, Query, Header, Cookie) are essential to ensure framework fixes are robust and do not regress in edge cases.