unlock unlimited code runs for $79, limited time →
NOSSLETTER.techLIVE
back to OSSpedia
error-handlingapi-designtesting-strategy

🐛 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 just a,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 Cookie Json[dict[str, str]] (list vs dict).

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 master branch 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 PASSED

Looks 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.