Skip to content

Explore JSON serialization fast path for typed responses via Pydantic dump_json #1683

@ftnext

Description

@ftnext

Thanks for building such an awesome framework.

Is your feature request related to a problem? Please describe.
When using response=... in Django Ninja, the response serialization path currently goes through:

  1. model_validate(...)
  2. model_dump(...) to Python objects
  3. json.dumps(...) in JSONRenderer

This likely leaves performance headroom for typed responses, especially compared to FastAPI 0.130.0, which added direct JSON-byte serialization via Pydantic's Rust core.

At the same time, a naive fast path can break compatibility by bypassing JSONRenderer customization (encoder_class, json_dumps_params, etc.). So the problem is both performance opportunity and defining the correct compatibility boundary.

Describe the solution you'd like
I would like to explore an official, compatibility-safe JSON serialization fast path for typed responses (response=...), based on Pydantic TypeAdapter.dump_json().

A practical approach could be one of:

  • Opt-in feature flag (safest default)
  • Conditional enablement only with default JSONRenderer settings
  • A dedicated high-performance JSON renderer with explicit behavior trade-offs

I already validated a prototype locally to confirm feasibility (see following patch):

  • Added a typed-response path using TypeAdapter.dump_json(...)
  • Allowed JSONRenderer to pass through already-serialized bytes
  • Cached per-status response adapters

If maintainers are interested in this direction, I can prepare a follow-up proposal/PR with a strict compatibility strategy and benchmarks.

diff --git a/ninja/operation.py b/ninja/operation.py
index a9b9a87..3ad4739 100644
--- a/ninja/operation.py
+++ b/ninja/operation.py
@@ -18,6 +18,7 @@ import pydantic
 from asgiref.sync import async_to_sync, sync_to_async
 from django.http import HttpRequest, HttpResponse, HttpResponseNotAllowed
 from django.http.response import HttpResponseBase
+from pydantic import TypeAdapter
 
 from ninja.compatibility.files import FIX_MIDDLEWARE_PATH, need_to_fix_request_files
 from ninja.constants import NOT_SET, NOT_SET_TYPE
@@ -28,6 +29,7 @@ from ninja.errors import (
     ValidationErrorContext,
 )
 from ninja.params.models import TModels
+from ninja.renderers import JSONRenderer
 from ninja.schema import Schema, pydantic_version
 from ninja.signature import ViewSignature, is_async
 from ninja.throttling import BaseThrottle
@@ -91,6 +93,7 @@ class Operation:
         self.models: TModels = self.signature.models
 
         self.response_models: Dict[Any, Any]
+        self.response_type_adapters: Dict[Any, TypeAdapter] = {}
         if response is NOT_SET:
             self.response_models = {200: NOT_SET}
         elif isinstance(response, dict):
@@ -160,6 +163,7 @@ class Operation:
 
         # Copy response models (dict copy for isolation)
         cloned.response_models = dict(self.response_models)
+        cloned.response_type_adapters = dict(self.response_type_adapters)
 
         # Copy metadata
         cloned.operation_id = self.operation_id
@@ -318,13 +322,31 @@ class Operation:
                 context={"request": request, "response_status": status}
             )
 
-        result = validated_object.model_dump(
-            by_alias=self.by_alias,
-            exclude_unset=self.exclude_unset,
-            exclude_defaults=self.exclude_defaults,
-            exclude_none=self.exclude_none,
-            **model_dump_kwargs,
-        )["response"]
+        if isinstance(self.api.renderer, JSONRenderer):
+            dump_json_kwargs: Dict[str, Any] = {}
+            if pydantic_version >= [2, 7]:
+                # pydantic added support for serialization context at 2.7
+                dump_json_kwargs.update(
+                    context={"request": request, "response_status": status}
+                )
+            response_adapter = self._get_response_type_adapter(status, response_model)
+            result = response_adapter.dump_json(
+                validated_object.response,
+                by_alias=self.by_alias,
+                exclude_unset=self.exclude_unset,
+                exclude_defaults=self.exclude_defaults,
+                exclude_none=self.exclude_none,
+                **dump_json_kwargs,
+            )
+        else:
+            result = validated_object.model_dump(
+                by_alias=self.by_alias,
+                exclude_unset=self.exclude_unset,
+                exclude_defaults=self.exclude_defaults,
+                exclude_none=self.exclude_none,
+                **model_dump_kwargs,
+            )["response"]
+
         return self.api.create_response(
             request, result, temporal_response=temporal_response
         )
@@ -367,6 +389,17 @@ class Operation:
         attrs = {"__annotations__": {"response": response_param}}
         return type("NinjaResponseSchema", (Schema,), attrs)
 
+    def _get_response_type_adapter(
+        self, status: int, response_model: Type[Schema]
+    ) -> TypeAdapter:
+        if status in self.response_type_adapters:
+            return self.response_type_adapters[status]
+
+        response_annotation = response_model.model_fields["response"].annotation
+        adapter: TypeAdapter = TypeAdapter(response_annotation)
+        self.response_type_adapters[status] = adapter
+        return adapter
+
 
 class AsyncOperation(Operation):
     def __init__(self, *args: Any, **kwargs: Any) -> None:
diff --git a/ninja/renderers.py b/ninja/renderers.py
index 16e53f3..5b715d4 100644
--- a/ninja/renderers.py
+++ b/ninja/renderers.py
@@ -22,4 +22,6 @@ class JSONRenderer(BaseRenderer):
     json_dumps_params: Mapping[str, Any] = {}
 
     def render(self, request: HttpRequest, data: Any, *, response_status: int) -> Any:
+        if isinstance(data, bytes):
+            return data
         return json.dumps(data, cls=self.encoder_class, **self.json_dumps_params)

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions