Skip to content

⚡️ Use TypeAdapter.validate_json instead of json.loads#15617

Open
dolfinus wants to merge 1 commit into
fastapi:masterfrom
dolfinus:feature/pydanticv2-validate-json-new
Open

⚡️ Use TypeAdapter.validate_json instead of json.loads#15617
dolfinus wants to merge 1 commit into
fastapi:masterfrom
dolfinus:feature/pydanticv2-validate-json-new

Conversation

@dolfinus

@dolfinus dolfinus commented May 27, 2026

Copy link
Copy Markdown
Contributor

#14962 replaced json.dumps with pydantic v2 TypeAdapter.serialize_json(). This is the same, but for body parsing - replace json.loads with TypeAdapter.validate_json().
See also #13949

Local tests gives +5-10% performance boost. The larger the request body, the larger the speedup.

Caveats - if route handle contains multiple fields with Body() annotation, json will be parsed for each field. Changing this requires substantial rewrite of body parsing, e.g. merging all body fields into one pydantic model and then calling it's model_validate_json() method to parse request body once.
I'm not ready for this type of change, so this can be considered as proof-of-concept.

Small benchmark

requirements.txt

fastapi
locust
py-spy

app.py

from http import HTTPStatus

from fastapi import FastAPI, Response
from pydantic import BaseModel

app = FastAPI()

class SimpleModel(BaseModel):
    a: str


@app.post("/data")
async def create_item(data: SimpleModel):
    return Response(status_code=HTTPStatus.NO_CONTENT)

if __name__ == "__main__":
    import uvicorn
    uvicorn.run(app, host="0.0.0.0", port=8000)

locustfile.py

import json

from locust import FastHttpUser, TaskSet, task


class FastAPITask(TaskSet):
    data = json.dumps({"a": "abc" * 10000})
    headers = {
        "Content-Type": "application/json",
    }

    @task
    def push_data(self):
        with self.user.rest(
            "POST",
            "data",
            data=self.data,
            headers=self.headers,
            name="/data",
        ) as response:
            response.js


class FastAPIUser(FastHttpUser):
    host = "http://localhost:8000/"
    tasks = [FastAPITask]
locust --web-host=0.0.0.0 --processes 4 FastAPIUser
record --duration 600 --output flamegraph.svg -- python -m uvicorn app:app --host 0.0.0.0 --port 8000 --log-level=warning

Before - 2228RPS, json.loads took 9.8% and type_adapter.validate_python took 1% = 11% combined
fastapi_with_json_loads.tar.gz
fastapi_with_json_loads

After - 2570RPS, type_adapter.validate_json took 7.17%
fastapi_with_typeadapter_json.tar.gz
fastapi_with_typeadapter_json

@dolfinus

Copy link
Copy Markdown
Contributor Author

This requires bumping pydantic to 2.10.0 or above because of this issue with validate_json appears on versions below pydantic-core 2.24.1. But github-actions-bot does not allow pyproject.toml modification (#13951), that's why "lowest-direct" tests are failing in CI.

@codspeed-hq

codspeed-hq Bot commented May 27, 2026

Copy link
Copy Markdown

Merging this PR will improve performance by 24.76%

⚠️ Different runtime environments detected

Some benchmarks with significant performance changes were compared across different runtime environments,
which may affect the accuracy of the results.

Open the report in CodSpeed to investigate

⚡ 2 improved benchmarks
✅ 18 untouched benchmarks

Performance Changes

Benchmark BASE HEAD Efficiency
test_async_receiving_large_payload 11.9 ms 9.5 ms +24.84%
test_sync_receiving_large_payload 12.1 ms 9.7 ms +24.67%

Tip

Curious why this is faster? Comment @codspeedbot explain why this is faster on this PR, or directly use the CodSpeed MCP with your agent.


Comparing dolfinus:feature/pydanticv2-validate-json-new (dd1189a) with master (59d4a80)1

Open in CodSpeed

Footnotes

  1. No successful run was found on master (dbfd55c) during the generation of this report, so 59d4a80 was used instead as the comparison base. There might be some changes unrelated to this pull request in this report.

@t0ugh-sys t0ugh-sys left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review: Use TypeAdapter.validate_json instead of json.loads

This is a solid performance-oriented PR that follows the same pattern as #14962 (which replaced json.dumps with TypeAdapter.serialize_json). Here are my observations:

👍 What's Good

  1. Performance improvement is well-motivated. The benchmark data shows ~15% RPS improvement (2228 → 2570) by avoiding the double-parsing overhead of json.loads followed by validate_python. Using validate_json lets Pydantic parse and validate in a single pass, which is a meaningful win for JSON-heavy workloads.

  2. Clean separation of is_body_json flag. Threading the is_body_json boolean through solve_dependenciesrequest_body_to_args is a clean way to defer JSON parsing until validation time, rather than eagerly calling request.json() in routing.

  3. Graceful multi-field fallback. The PR correctly handles the case where multiple Body() fields exist — falling back to json.loads since there's no single TypeAdapter to validate against. The inline comment makes this limitation clear.

  4. Error handling preservation. The parse_json helper constructs error dicts that match Pydantic's error format (json_invalid type, loc, ctx with pos/lineno/colno), maintaining backward compatibility for API consumers.

  5. The _validate_json_body_as_model_field error post-processing is clever — it detects when Pydantic returns bytes in the input field and re-parses it to a dict, which is more user-friendly in error responses.

⚠️ Potential Issues & Questions

  1. Breaking change in error response format for invalid JSON. The body field in validation error responses changes from a parsed dict to a JSON string (e.g., '{"title": "towel", "size": "XL"}'). This is a user-visible behavioral change that could break clients parsing error responses. It's documented in the updated tutorial docs, but worth noting — consumers relying on body being a dict will need to update.

  2. Error message inconsistency for single vs. multi-field routes. For a single non-embedded field, invalid JSON errors go through _validate_json_body_as_model_field → Pydantic's validator (e.g., "msg": "Invalid JSON: key must be a string..."). For multi-field routes, they go through parse_json → stdlib's message format (e.g., "msg": "Invalid JSON: Expecting property name enclosed in double quotes"). The test at test_tutorial002.py confirms this — the messages differ. This inconsistency could confuse users.

  3. loc tuple change for JSON decode errors. Previously, the error loc was ("body", e.pos) (with the byte position). Now it's just ("body",) — the position info is moved to ctx. This is arguably better (position is context, not a location path), but it's another user-visible change.

  4. The _validate_json_body_as_model_field always calls json.loads on error input. If the body is large and validation fails, this re-parses the bytes just to populate the input field. For large payloads with validation errors, this could negate some of the performance gain on the error path. Consider whether displaying raw bytes (or truncating) might be acceptable instead.

  5. Removed values parameter from _validate_value_with_model_field. The values arg was removed from _validate_value_with_model_field and from field.validate() calls. This is a good cleanup if unused, but I'd want to confirm that the validate method signature in _compat/v2.py truly doesn't use the values parameter for anything (e.g., for validators that depend on sibling fields).

  6. No content-type + no strict → always JSON. The logic elif not actual_strict_content_type: is_body_json = True means that when there's no Content-Type header and strict mode is off, the body is always treated as JSON. This preserves the existing behavior, but worth noting that this is a policy decision.

💡 Suggestions

  1. Consider unifying error message formats. Could parse_json() reuse Pydantic's JSON parser internally (e.g., pydantic_core.from_json) to get consistent error messages for both single and multi-field paths?

  2. Add a comment or docstring to _validate_json_body_as_model_field explaining why the input bytes → dict conversion is necessary, for future maintainers.

  3. The IsOneOf workaround in test_handling_errors for httpx compact JSON is pragmatic, but it might be worth tracking as a follow-up to pin the expected format once httpx stabilizes.

Overall, this is a well-executed PR with clear performance benefits and thoughtful handling of edge cases. The main concern is the backward-incompatible change in error response format (body becoming a string), which should be highlighted in release notes if merged. 🚀

@golikovichev golikovichev left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The direct validate_json path avoids the double pass of json.loads followed by model validation, so the intent makes sense. Two things I would want to confirm before this lands, plus one question.

  1. Error body shape change. The docs diff shows the validation error body going from a parsed object to the raw JSON string, with an added input field. That is a user-visible change for anyone whose exception handler or tests read exc.body as a dict. It may well be more correct, but it is a breaking change for error-handling code, so it is worth calling out in the PR description and the release notes.

  2. solve_dependencies signature. The body parameter is narrowed from dict[str, Any] | FormData | bytes | None to bytes | FormData | None, and a new is_body_json flag is added. solve_dependencies gets imported and sometimes wrapped downstream. If it is treated as semi-public, the narrowed type plus the new keyword could surprise callers. Would a compatible default cover that?

  3. Benchmark. The PR is tagged as a performance change. Do you have before and after numbers on a representative payload? Even a small benchmark on a nested model would make the perf claim concrete.

Happy to test a branch build against a few real request shapes if that helps.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants