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

  • data is 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.

Prev
Next