Skip to Content
FeaturesDRF GuideViewSets & Serializers

DRF ViewSets & Serializers

Writing ViewSets and serializers that produce clean OpenAPI schemas for type-safe client generation.

Core Pattern

Django-CFG uses GenericViewSet + @action pattern exclusively. Standard CRUD ViewSets (ModelViewSet, ReadOnlyModelViewSet) also work but custom actions are preferred for explicit API contracts.

ViewSet Requirements

Always set serializer_class

Every ViewSet must have a serializer_class defined. Without it, drf-spectacular cannot generate schemas.

# ✅ Correct class DashboardViewSet(viewsets.GenericViewSet): serializer_class = DashboardSerializer # Required! @action(detail=False, methods=['get']) def overview(self, request): ... # ❌ Wrong - will cause generation errors class DashboardViewSet(viewsets.GenericViewSet): # Missing serializer_class! @action(detail=False, methods=['get']) def overview(self, request): ...

Use get_serializer_class() for multiple serializers

When a ViewSet uses different serializers per action, implement get_serializer_class():

class UserViewSet(viewsets.GenericViewSet): serializer_class = UserSerializer # Default (fallback) def get_serializer_class(self): if self.action == 'create': return UserCreateSerializer elif self.action == 'update': return UserUpdateSerializer return UserSerializer

Decorator order matters

@extend_schema must come before @action:

# ✅ Correct order @extend_schema( summary="Get dashboard overview", responses={200: DashboardSerializer}, tags=["dashboard"] ) @action(detail=False, methods=['get']) def overview(self, request): ... # ❌ Wrong order - causes wrong tags, operationIds, serializer refs @action(detail=False, methods=['get']) @extend_schema( summary="Get dashboard overview", responses={200: DashboardSerializer}, tags=["dashboard"] ) def overview(self, request): ...

Serializer Best Practices

Use read_only_fields for detail serializers

To prevent TypeScript type splitting (Readable/Writable variants), make all fields read-only in response serializers:

class UserDetailSerializer(serializers.ModelSerializer): class Meta: model = User fields = ['id', 'email', 'name', 'created_at', 'is_active'] read_only_fields = fields # All read-only → single TS type

Without this, the generator may create UserDetailReadable and UserDetailWritable types, and references to the base UserDetail type will break.

Separate read/write serializers

For endpoints with both input and output, use separate serializers:

# Response serializer (read-only) class UserSerializer(serializers.ModelSerializer): class Meta: model = User fields = ['id', 'email', 'name', 'avatar', 'created_at'] read_only_fields = fields # Request serializer (writable) class UserCreateSerializer(serializers.Serializer): email = serializers.EmailField() name = serializers.CharField(max_length=100) password = serializers.CharField(write_only=True)

Explicit responses in @extend_schema

Always use dict format with status codes:

@extend_schema( request=UserCreateSerializer, responses={ 201: UserSerializer, 400: ErrorSerializer, } ) @action(detail=False, methods=['post']) def register(self, request): ...

The dict format {200: Serializer} produces better TypeScript types than the bare responses=Serializer format.

Tag Grouping

Single ViewSet = Single tag

All actions in one ViewSet should use the same primary tag. Different tags cause endpoint splitting across multiple generated files.

# ✅ Correct - consistent tag class DashboardViewSet(viewsets.GenericViewSet): @extend_schema(tags=["dashboard"]) @action(detail=False, methods=['get']) def overview(self, request): ... @extend_schema(tags=["dashboard"]) @action(detail=False, methods=['get']) def stats(self, request): ...
# ❌ Wrong - different tags split into separate files class DashboardViewSet(viewsets.GenericViewSet): @extend_schema(tags=["Statistics"]) # → fetchers/statistics.ts @action(...) def stats(self, request): ... @extend_schema(tags=["System Health"]) # → fetchers/system_health.ts @action(...) def health(self, request): ...

Multiple tags for documentation

Use multiple tags where the first tag determines file grouping:

@extend_schema( tags=["dashboard", "Statistics"], # Grouped under "dashboard" responses={200: StatSerializer(many=True)} )

Tag naming

Use lowercase tags with underscores:

  • dashboard — all endpoints grouped in one file
  • user_management — clear naming
  • Admin Panel - Commands — spaces and dashes work but create verbose file names

Complete Example

from drf_spectacular.utils import extend_schema, extend_schema_view from rest_framework import viewsets, permissions, status from rest_framework.decorators import action from rest_framework.response import Response @extend_schema_view( list=extend_schema( summary="List users", tags=["users"], ), retrieve=extend_schema( summary="Get user details", tags=["users"], ), ) class UserViewSet(viewsets.ReadOnlyModelViewSet): queryset = User.objects.all() serializer_class = UserSerializer permission_classes = [permissions.IsAuthenticated] def get_serializer_class(self): if self.action == 'list': return UserListSerializer return UserSerializer @extend_schema( summary="Get current user profile", responses={200: UserSerializer}, tags=["users"], ) @action(detail=False, methods=['get'], url_path='me') def me(self, request): serializer = UserSerializer(request.user) return Response(serializer.data) @extend_schema( summary="Update profile", request=UserUpdateSerializer, responses={200: UserSerializer}, tags=["users"], ) @action(detail=False, methods=['patch'], url_path='me') def update_me(self, request): serializer = UserUpdateSerializer( request.user, data=request.data, partial=True ) serializer.is_valid(raise_exception=True) serializer.save() return Response(UserSerializer(request.user).data)

Checklist

Before running client generation, verify:

  • Every ViewSet has serializer_class defined
  • @extend_schema comes before @action decorators
  • Responses use dict format: {200: Serializer}
  • Detail serializers use read_only_fields = fields
  • All actions in a ViewSet use the same primary tag
  • Read/write operations use separate serializers

Next Steps

Last updated on