Django REST Framework is the de facto standard for Python APIs. Its conventions — serializers for validation and representation, ViewSets for CRUD, permissions for authorization — are patterns Claude Code handles well. With the right CLAUDE.md, Claude Code generates DRF code that follows DRF conventions rather than reimplementing them from scratch.
This guide covers DRF with Claude Code: serializers, ViewSets, authentication, filtering, and testing.
DRF Project Setup
CLAUDE.md for DRF Projects
## Django REST Framework API
- Python 3.12, Django 5.x, DRF 3.15+
- Database: PostgreSQL via psycopg3
- Auth: JWT via djangorestframework-simplejwt
- serializers: always use serializer.is_valid(raise_exception=True)
- Permissions: use DRY_REST_PERMISSIONS or custom permission classes
- Filtering: django-filter + DRF filter backends
- Pagination: PageNumberPagination, default 20 items/page
- Tests: pytest-django, factory-boy for fixtures
## Project structure
- apps/{resource}/models.py — Django models
- apps/{resource}/serializers.py — Input/output serializers (separate when needed)
- apps/{resource}/views.py — ViewSets
- apps/{resource}/urls.py — Router registration
- apps/{resource}/permissions.py — Custom permissions
- apps/{resource}/filters.py — FilterSet classes
- apps/{resource}/tests/ — Test modules
## Conventions
- ModelViewSet for standard CRUD, GenericAPIView for custom endpoints
- Separate input/output serializers when representation ≠ input
- Use select_related/prefetch_related in get_queryset() — don't add it to individual actions
- Custom actions: @action(detail=True/False, methods=['post'])
Settings
# config/settings/base.py (relevant DRF section)
REST_FRAMEWORK = {
'DEFAULT_AUTHENTICATION_CLASSES': [
'rest_framework_simplejwt.authentication.JWTAuthentication',
],
'DEFAULT_PERMISSION_CLASSES': [
'rest_framework.permissions.IsAuthenticated',
],
'DEFAULT_PAGINATION_CLASS': 'apps.core.pagination.StandardPagination',
'PAGE_SIZE': 20,
'DEFAULT_FILTER_BACKENDS': [
'django_filters.rest_framework.DjangoFilterBackend',
'rest_framework.filters.SearchFilter',
'rest_framework.filters.OrderingFilter',
],
'DEFAULT_RENDERER_CLASSES': [
'rest_framework.renderers.JSONRenderer',
],
'DEFAULT_SCHEMA_CLASS': 'drf_spectacular.openapi.AutoSchema',
'EXCEPTION_HANDLER': 'apps.core.exceptions.custom_exception_handler',
}
SIMPLE_JWT = {
'ACCESS_TOKEN_LIFETIME': timedelta(minutes=15),
'REFRESH_TOKEN_LIFETIME': timedelta(days=7),
'ROTATE_REFRESH_TOKENS': True,
'BLACKLIST_AFTER_ROTATION': True,
}
Models and Serializers
Create a blog Post model with author, tags, and published state.
Need CRUD API with separate serializers for list vs detail view.
# apps/blog/models.py
from django.db import models
from django.contrib.auth import get_user_model
User = get_user_model()
class Tag(models.Model):
name = models.CharField(max_length=50, unique=True)
slug = models.SlugField(unique=True)
def __str__(self):
return self.name
class Post(models.Model):
class Status(models.TextChoices):
DRAFT = 'draft', 'Draft'
PUBLISHED = 'published', 'Published'
ARCHIVED = 'archived', 'Archived'
title = models.CharField(max_length=255)
slug = models.SlugField(max_length=255, unique=True)
content = models.TextField()
excerpt = models.TextField(max_length=500, blank=True)
author = models.ForeignKey(User, on_delete=models.PROTECT, related_name='posts')
tags = models.ManyToManyField(Tag, blank=True, related_name='posts')
status = models.CharField(max_length=20, choices=Status.choices, default=Status.DRAFT)
published_at = models.DateTimeField(null=True, blank=True)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
ordering = ['-published_at', '-created_at']
indexes = [
models.Index(fields=['status', '-published_at']),
models.Index(fields=['author', 'status']),
]
def __str__(self):
return self.title
# apps/blog/serializers.py
from rest_framework import serializers
from .models import Post, Tag
class TagSerializer(serializers.ModelSerializer):
class Meta:
model = Tag
fields = ['id', 'name', 'slug']
read_only_fields = ['slug']
class AuthorSerializer(serializers.Serializer):
"""Minimal author representation for embedding in posts."""
id = serializers.IntegerField()
username = serializers.CharField()
full_name = serializers.SerializerMethodField()
def get_full_name(self, obj):
return obj.get_full_name() or obj.username
# List view — minimal fields for performance
class PostListSerializer(serializers.ModelSerializer):
author = AuthorSerializer(read_only=True)
tag_count = serializers.IntegerField(read_only=True)
class Meta:
model = Post
fields = ['id', 'title', 'slug', 'excerpt', 'author', 'tag_count',
'status', 'published_at', 'created_at']
# Detail view — full content
class PostDetailSerializer(serializers.ModelSerializer):
author = AuthorSerializer(read_only=True)
tags = TagSerializer(many=True, read_only=True)
tag_ids = serializers.PrimaryKeyRelatedField(
queryset=Tag.objects.all(), many=True, write_only=True, source='tags'
)
class Meta:
model = Post
fields = ['id', 'title', 'slug', 'content', 'excerpt', 'author',
'tags', 'tag_ids', 'status', 'published_at', 'created_at', 'updated_at']
read_only_fields = ['id', 'slug', 'author', 'created_at', 'updated_at']
def validate(self, data):
if data.get('status') == Post.Status.PUBLISHED and not data.get('content'):
raise serializers.ValidationError('Published posts must have content.')
return data
ViewSets
Write the ViewSet with list/detail/create/update/delete.
Only the author can edit their own posts.
Anyone can read published posts.
# apps/blog/views.py
from django.db.models import Count
from django.utils import timezone
from rest_framework import viewsets, permissions, status
from rest_framework.decorators import action
from rest_framework.response import Response
from .models import Post
from .serializers import PostListSerializer, PostDetailSerializer
from .permissions import IsAuthorOrReadOnly
from .filters import PostFilter
class PostViewSet(viewsets.ModelViewSet):
permission_classes = [permissions.IsAuthenticatedOrReadOnly, IsAuthorOrReadOnly]
filterset_class = PostFilter
search_fields = ['title', 'content', 'author__username']
ordering_fields = ['created_at', 'published_at', 'title']
ordering = ['-published_at']
def get_queryset(self):
queryset = Post.objects.select_related('author').prefetch_related('tags')
# Annotate with tag count for list serializer
queryset = queryset.annotate(tag_count=Count('tags'))
# Unauthenticated users see only published posts
if not self.request.user.is_authenticated:
return queryset.filter(status=Post.Status.PUBLISHED)
# Authors see their own drafts + published posts by others
return queryset.filter(
models.Q(status=Post.Status.PUBLISHED) |
models.Q(author=self.request.user)
)
def get_serializer_class(self):
# Use minimal serializer for list view (avoids N+1 on content field)
if self.action == 'list':
return PostListSerializer
return PostDetailSerializer
def perform_create(self, serializer):
slug = slugify(serializer.validated_data['title'])
if serializer.validated_data.get('status') == Post.Status.PUBLISHED:
serializer.save(author=self.request.user, slug=slug, published_at=timezone.now())
else:
serializer.save(author=self.request.user, slug=slug)
@action(detail=True, methods=['post'], url_path='publish')
def publish(self, request, pk=None):
"""Publish a draft post."""
post = self.get_object()
if post.status == Post.Status.PUBLISHED:
return Response({'detail': 'Already published.'}, status=status.HTTP_400_BAD_REQUEST)
post.status = Post.Status.PUBLISHED
post.published_at = timezone.now()
post.save(update_fields=['status', 'published_at'])
return Response(PostDetailSerializer(post, context={'request': request}).data)
# apps/blog/permissions.py
from rest_framework import permissions
class IsAuthorOrReadOnly(permissions.BasePermission):
"""Object-level permission: only the author can modify."""
def has_object_permission(self, request, view, obj):
if request.method in permissions.SAFE_METHODS:
return True
return obj.author == request.user
Filtering
Add filtering for status, author, date range, and tag.
Also full-text search by title and content.
# apps/blog/filters.py
import django_filters
from .models import Post, Tag
class PostFilter(django_filters.FilterSet):
status = django_filters.ChoiceFilter(choices=Post.Status.choices)
author = django_filters.CharFilter(field_name='author__username')
tag = django_filters.CharFilter(field_name='tags__slug')
published_after = django_filters.DateTimeFilter(field_name='published_at', lookup_expr='gte')
published_before = django_filters.DateTimeFilter(field_name='published_at', lookup_expr='lte')
class Meta:
model = Post
fields = ['status', 'author', 'tag']
Now the API supports: GET /api/posts/?status=published&tag=python&published_after=2026-01-01&search=django&ordering=-published_at
Testing with pytest-django
Write tests for the PostViewSet.
Test permissions, filtering, and the publish action.
# apps/blog/tests/test_views.py
import pytest
from django.urls import reverse
from rest_framework import status
from rest_framework.test import APIClient
from apps.blog.tests.factories import PostFactory, UserFactory, TagFactory
@pytest.mark.django_db
class TestPostViewSet:
def test_list_published_posts_unauthenticated(self, api_client):
published = PostFactory(status='published')
PostFactory(status='draft') # Should not appear
response = api_client.get('/api/posts/')
assert response.status_code == 200
assert len(response.data['results']) == 1
assert response.data['results'][0]['id'] == published.id
def test_author_sees_own_drafts(self, api_client):
user = UserFactory()
api_client.force_authenticate(user=user)
own_draft = PostFactory(author=user, status='draft')
other_draft = PostFactory(status='draft') # Other user's draft
response = api_client.get('/api/posts/')
ids = [p['id'] for p in response.data['results']]
assert own_draft.id in ids
assert other_draft.id not in ids
def test_publish_action(self, api_client):
user = UserFactory()
api_client.force_authenticate(user=user)
post = PostFactory(author=user, status='draft')
response = api_client.post(f'/api/posts/{post.id}/publish/')
assert response.status_code == 200
post.refresh_from_db()
assert post.status == 'published'
assert post.published_at is not None
def test_non_author_cannot_update(self, api_client):
user = UserFactory()
api_client.force_authenticate(user=user)
post = PostFactory(status='published') # Different author
response = api_client.patch(f'/api/posts/{post.id}/', {'title': 'Hacked'})
assert response.status_code == status.HTTP_403_FORBIDDEN
post.refresh_from_db()
assert post.title != 'Hacked'
@pytest.fixture
def api_client():
return APIClient()
For building a Python microservice that exposes this DRF API, see the Python FastAPI guide — FastAPI is the alternative for teams that prefer async Python. For containerizing Django with Docker, the Docker guide covers the multi-stage Dockerfile pattern for Python. The Claude Skills 360 bundle includes Django and DRF skill sets for production API patterns. Start with the free tier to generate ViewSets and serializers for your models.