Django DRF-Spectacular: Documenting Custom Envelope Responses
Many teams using Django REST Framework (DRF) adopt a custom response envelope to keep API responses consistent across services. A common pattern looks like this:
{
"success": true,
"message": "OK",
"data": {
"id": 1,
"name": "Alice"
}
}
While this structure is convenient for frontend applications, it often causes confusion when generating OpenAPI documentation with drf-spectacular. By default, drf-spectacular assumes that your serializer represents the entire response body, which is not true when you wrap data inside a custom envelope.
This article explains why this mismatch happens and shows clean, maintainable ways to document custom envelope responses correctly.
Why drf-spectacular Gets It Wrong by Default
In DRF, serializers usually describe the exact JSON returned by the API. When you return a wrapped response manually—such as {"success": true, "data": serializer.data}—the schema generator has no way to infer that extra structure.
As a result, the generated OpenAPI schema typically documents only the inner serializer, misleading API consumers and frontend developers.
Option 1: Create an Explicit Envelope Serializer (Recommended)
The most explicit and maintainable solution is to define a reusable envelope serializer.
from rest_framework import serializers
class EnvelopeSerializer(serializers.Serializer):
success = serializers.BooleanField()
message = serializers.CharField(required=False)
data = serializers.JSONField()
Then, use extend_schema to document the response:
from drf_spectacular.utils import extend_schema
@extend_schema(
responses=EnvelopeSerializer
)
def get(self, request):
...
Pros
- Clear and explicit schema
- Reusable across endpoints
- Works perfectly with schema generation
Cons
datais loosely typed unless customized further
Option 2: Typed Envelope with Generic Pattern
For better typing, you can define multiple envelope serializers for different payloads:
class UserDataSerializer(serializers.Serializer):
id = serializers.IntegerField()
name = serializers.CharField()
class UserEnvelopeSerializer(serializers.Serializer):
success = serializers.BooleanField()
message = serializers.CharField(required=False)
data = UserDataSerializer()
This produces fully accurate OpenAPI documentation at the cost of some duplication.
Option 3: Inline Schema Override
For simple or one-off endpoints, you can define the envelope inline:
from drf_spectacular.utils import extend_schema
from drf_spectacular.types import OpenApiTypes
@extend_schema(
responses={
200: {
"type": "object",
"properties": {
"success": {"type": "boolean"},
"message": {"type": "string"},
"data": {"type": "object"},
}
}
}
)
def get(self, request):
...
When to use this
- Prototyping
- Simple endpoints
- Minimal reuse expected
Should You Use a Global Envelope?
Some teams try to enforce a global envelope by overriding APIView.finalize_response. While this works at runtime, it complicates schema generation and often results in inaccurate OpenAPI docs.
If you choose this approach, explicit schema definitions are mandatory—automatic inference will not work.
Best Practices Summary
- Prefer explicit envelope serializers
- Keep schema definitions close to the view
- Avoid magic global overrides for documentation
- Treat OpenAPI as a first-class contract, not a side effect
Final Thoughts
drf-spectacular is powerful, but it cannot guess response shapes that serializers do not describe. Once you accept that limitation, documenting custom envelope responses becomes straightforward and predictable.
Clear schemas lead to happier frontend developers, fewer integration bugs, and APIs that scale gracefully.