-
-
Notifications
You must be signed in to change notification settings - Fork 565
Description
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:
model_validate(...)model_dump(...)to Python objectsjson.dumps(...)inJSONRenderer
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
JSONRenderersettings - 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
JSONRendererto pass through already-serializedbytes - 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)