Claude Code for Django REST Framework: Building Production APIs — Claude Skills 360 Blog
Blog / Development / Claude Code for Django REST Framework: Building Production APIs
Development

Claude Code for Django REST Framework: Building Production APIs

Published: June 22, 2026
Read time: 10 min read
By: Claude Skills 360

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.

Put these ideas into practice

Claude Skills 360 gives you production-ready skills for everything in this article — and 2,350+ more. Start free or go all-in.

Back to Blog

Get 360 skills free