Pydantic v2 Response Schemas
Django-CFG has built-in support for using Pydantic v2 BaseModel classes directly in @extend_schema(responses=...). No DRF serializer needed for responses — Pydantic generates the OpenAPI schema automatically, and django-cfg client generator produces typed Zod schemas and TypeScript interfaces from it.
Zero configuration required
drf-spectacular ships with PydanticExtension that handles Pydantic v2 models automatically. Django-CFG enables OAS_VERSION: "3.1.0" by default, which is required for Pydantic’s nullable fields (str | None) to generate valid OpenAPI.
The Pattern
Service method → returns Pydantic model
View → Response(result.model_dump())
@extend_schema → responses={200: MyPydanticModel}
drf-spectacular → generates OpenAPI schema from Pydantic JSON schema
django-cfg → generates TypeScript interface + Zod schemaCompare to the old approach that required duplicate DRF serializers:
# ❌ Old: write DRF serializer just to describe the response shape
class ExtractDialogueResponseSerializer(serializers.Serializer):
segments = DialogueSegmentSerializer(many=True)
scene_context = serializers.CharField()
motion_description = serializers.CharField()
@extend_schema(responses={200: ExtractDialogueResponseSerializer})
def extract_dialogue(self, request):
result = DialogueService.extract_dialogue(scene)
return Response(ExtractDialogueResponseSerializer(result).data)# ✅ New: define once in Pydantic, use everywhere
class ExtractDialogueResult(BaseModel):
segments: list[DialogueSegmentOut]
scene_context: str
motion_description_display: str
motion_description_en: str
@extend_schema(responses={200: ExtractDialogueResult})
def extract_dialogue(self, request):
result = DialogueService.extract_dialogue(scene)
return Response(result.model_dump())Setting Up Service Schemas
Place Pydantic response schemas in a dedicated schemas.py next to the service:
apps/
generation/
services/
dialogue.py ← service logic
schemas.py ← Pydantic response models# apps/generation/services/schemas.py
from pydantic import BaseModel, Field
class DialogueSegmentOut(BaseModel):
order: int
character_name: str
text: str
emotion: str
direction: str | None
estimated_duration: float
class ExtractDialogueResult(BaseModel):
segments: list[DialogueSegmentOut]
scene_context: str
motion_description_display: str
motion_description_en: str
class GenerateDialogueResult(BaseModel):
status: str
reason: str = ""
segments: int = 0
tracks_created: int = 0
track_ids: list[str] = Field(default_factory=list)
tts_tasks: list[str] = Field(default_factory=list)
motion_description: str = ""
class ScreenwriteResult(BaseModel):
formatted_script: str
dialogue_segments: list[DialogueSegmentOut]
optimized_motion_prompt: str
recommended_duration: int
confidence: float
optimization_notes: strService Returns Pydantic
The service constructs and returns a Pydantic object — full type safety inside Python:
# apps/generation/services/dialogue.py
from apps.generation.services.schemas import ExtractDialogueResult, DialogueSegmentOut
class DialogueService:
@staticmethod
def extract_dialogue(scene: Scene, description: str = "") -> ExtractDialogueResult:
...
result = llm_service.extract(description, chars)
return ExtractDialogueResult(
segments=[DialogueSegmentOut(**s.model_dump()) for s in result.segments],
scene_context=result.scene_context,
motion_description_display=result.motion_description_display,
motion_description_en=result.motion_description_en,
)ViewSet Uses .model_dump()
Import Pydantic schemas directly into the view and pass to responses=:
# apps/generation/api/views/generation.py
from apps.generation.services.schemas import (
ExtractDialogueResult,
GenerateDialogueResult,
ScreenwriteResult,
)
class GenerationViewSet(GenericViewSet):
@extend_schema(
request=ExtractDialogueRequestSerializer,
responses={200: ExtractDialogueResult}, # ← Pydantic class directly
)
@action(detail=False, methods=["post"], url_path="extract-dialogue")
def extract_dialogue(self, request, project_pk=None):
serializer = ExtractDialogueRequestSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
...
result = DialogueService.extract_dialogue(scene, data.get("description", ""))
return Response(result.model_dump()) # ← .model_dump() for Response
@extend_schema(
request=GenerateDialogueRequestSerializer,
responses={200: GenerateDialogueResult},
)
@action(detail=False, methods=["post"], url_path="generate-dialogue")
def generate_dialogue(self, request, project_pk=None):
...
result = DialogueService.generate_dialogue(scene, description, target_model=model)
return Response(result.model_dump())
@extend_schema(
request=ScreenwriteRequestSerializer,
responses={200: ScreenwriteResult},
)
@action(detail=False, methods=["post"], url_path="screenwrite")
def screenwrite(self, request, project_pk=None):
...
result = DialogueService.screenwrite(scene, brief=brief, target_model=model)
return Response(result.model_dump())Request bodies still need DRF serializers
Pydantic is only for responses. DRF serializers are still required for request validation (request=MySerializer). serializer.is_valid(raise_exception=True) uses DRF’s validation pipeline.
What Gets Generated
After make gen, the client generator produces:
Zod schema (ExtractDialogueResult.schema.ts):
import { z } from 'zod'
import { DialogueSegmentOutSchema } from './DialogueSegmentOut.schema'
export const ExtractDialogueResultSchema = z.object({
segments: z.array(DialogueSegmentOutSchema),
scene_context: z.string(),
motion_description_display: z.string(),
motion_description_en: z.string(),
})
export type ExtractDialogueResult = z.infer<typeof ExtractDialogueResultSchema>Typed fetcher (fetchers/scenes__api__generation.ts):
import { ExtractDialogueResultSchema, type ExtractDialogueResult } from '../schemas/ExtractDialogueResult.schema'
export async function postGenerationExtractDialogue(
project_pk: string,
data: ExtractDialogueRequest,
): Promise<ExtractDialogueResult> {
const response = await apiClient.post(...)
return ExtractDialogueResultSchema.parse(response)
}Nested models (like DialogueSegmentOut) get their own schema files and are cross-referenced — exactly like named DRF serializers.
Nullable Fields
Pydantic’s str | None generates anyOf: [{type: string}, {type: null}] which is valid OpenAPI 3.1 syntax. Django-CFG sets OAS_VERSION: "3.1.0" by default so this works out of the box.
class MyResult(BaseModel):
optional_field: str | None = None # ✅ works with OAS 3.1.0
required_field: str # ✅ always works
list_field: list[str] = [] # ✅ generates array typeIf you manually set OAS_VERSION: "3.0.3" in your project’s spectacular settings, Pydantic nullable fields will cause schema validation errors. Either upgrade to 3.1.0 or avoid str | None in response models.
When to Use DRF Serializer vs Pydantic
| Scenario | Use |
|---|---|
| Request validation (input from user) | DRF Serializer |
| Response from service layer | Pydantic BaseModel |
| ModelSerializer (ORM → JSON) | DRF ModelSerializer |
| LLM structured output | Pydantic BaseModel |
| Complex nested response built manually | Pydantic BaseModel |
| Simple response wrapping ORM queryset | DRF Serializer |
How It Works Internally
drf-spectacular ships with PydanticExtension that:
- Detects any
pydantic.BaseModelsubclass passed toresponses= - Calls
pydantic.json_schema.model_json_schema(cls, mode="serialization")to get the JSON schema - Extracts nested models from
$defsinto OpenAPIcomponents/schemas - Returns the root schema with
$refpointers to components
You can verify it’s active:
from drf_spectacular.extensions import OpenApiSerializerExtension
exts = OpenApiSerializerExtension._registry
print([e for e in exts if 'pydantic' in str(e).lower()])
# [<class 'drf_spectacular.contrib.pydantic.PydanticExtension'>]No registration or configuration needed — it’s loaded automatically with drf-spectacular.