Get Started with Python Django REST Framework: Your First Concrete Step Today
Python Django REST Framework: the essentials in one article — real code, diagrams and concrete steps, excerpts from a 24-lesson course.
The best way to learn Python Django REST Framework is by doing. This article gives you a head start with practical excerpts from a 24-lesson course — enough to get your first results today.
tl;dr
- Setup DRF
- Serializers
- Views
- Auth and permissions
- Filters and pagination
~$ cat ./parcours.md # Python Django REST Framework — 8 chapters
01
Setup DRF
→ REST vs RPC vs GraphQL→ Installation and first endpoint+ 1 more lessons
02
Serializers
→ Serializer and ModelSerializer→ Custom validation+ 1 more lessons
03
Views
→ APIView and generics→ ViewSets and Routers+ 1 more lessons
04
Auth and permissions
→ Token, Session, JWT→ Builtin and custom permissions+ 1 more lessons
05
Filters and pagination
→ Filters and search→ Pagination+ 1 more lessons
06
OpenAPI documentation
→ drf-spectacular→ Swagger UI and Redoc+ 1 more lessons
07
Tests and performance
→ APITestCase and factories→ N+1 optimization+ 1 more lessons
08
Final project API SaaS
→ Multi-tenant architecture→ Endpoints and tests+ 1 more lessons
🏁
Final project
→ You leave with a concrete and demonstrable project
REST vs RPC vs GraphQL
REST: resources and verbs
GET /api/articles/ # list GET /api/articles/42/ # detail POST /api/articles/ # create PUT /api/articles/42/ # replace PATCH /api/articles/42/ # partial update DELETE /api/articles/42/ # delete # HTTP verbs + URIs = universal CRUD
Comparison
| Aspect | REST | GraphQL | RPC (gRPC) |
|---|---|---|---|
| Format | JSON/XML | JSON | Protobuf |
| Transport | HTTP | HTTP | HTTP/2 |
| Schema | OpenAPI | SDL (typed) | .proto |
| Versioning | v1/v2 | deprecated fields | backward compat |
| Over-fetch | Yes | No | No |
| HTTP Cache | Easy | Hard | No |
| Tooling | Excellent | Good | Tech-specific |
| Mobile/IoT | OK | Excellent | Excellent |
When to choose what
NOTEREST — Public APIs, simple CRUD, classic web/mobile, cacheable. Default for 90% of cases.
NOTEGraphQL — Complex mobile with screens aggregating 5+ resources, multiple frontends with different needs.
NOTEgRPC — Internal microservices, critical latency, bidirectional streaming, multiple languages.
Why DRF?
Alternatives to DRF
Anatomy of a good REST API
# Best practices GET /api/v1/users/ # list GET /api/v1/users/42/ # detail GET /api/v1/users/42/orders/ # sub-resource POST /api/v1/users/ # 201 Created + Location PATCH /api/v1/users/42/ # partial DELETE /api/v1/users/42/ # 204 No Content # Filters / pagination GET /api/v1/users/?role=admin&page=2&page_size=20 # Status codes 200 OK, 201 Created, 204 No Content 400 Bad Request, 401 Unauthorized, 403 Forbidden 404 Not Found, 409 Conflict, 422 Unprocessable 500 Server Error, 503 Unavailable
Standard response format
{
"data": [...],
"meta": {
"count": 150,
"page": 2,
"page_size": 20
},
"links": {
"next": "/api/v1/users/?page=3",
"prev": "/api/v1/users/?page=1"
}
}
# Errors
{
"error": {
"code": "VALIDATION_ERROR",
"message": "Email is required",
"details": {"email": ["This field is required"]}
}
}Summary
NOTEKey takeaways
- REST = resources + standardized HTTP verbs
- GraphQL for complex mobile, REST for everything else
- DRF = Django standard since 2014
- v1/v2 in URL, JSON format, correct HTTP codes
Multi-tenant architecture
Requirements
NOTEProject — Multi-tenant SaaS API for project/task management with pricing plans (Free/Pro/Enterprise), Stripe billing, webhooks and public OpenAPI.
Models
# workspaces/models.py class Workspace(models.Model): class Plan(models.TextChoices): FREE = "free", "Free" PRO = "pro", "Pro" ENTERPRISE = "enterprise", "Enterprise" name = models.CharField(max_length=100) slug = models.SlugField(unique=True) plan = models.CharField(max_length=20, choices=Plan.choices, default=Plan.FREE) stripe_customer_id = models.CharField(max_length=100, blank=True) created_at = models.DateTimeField(auto_now_add=True) class Membership(models.Model): class Role(models.TextChoices): OWNER = "owner" ADMIN = "admin" MEMBER = "member" GUEST = "guest" workspace = models.ForeignKey(Workspace, on_delete=models.CASCADE, related_name="memberships") user = models.ForeignKey("accounts.User", on_delete=models.CASCADE) role = models.CharField(max_length=20, choices=Role.choices, default=Role.MEMBER) invited_at = models.DateTimeField(auto_now_add=True) class Meta: unique_together = ["workspace", "user"] # projects/models.py class Project(models.Model): workspace = models.ForeignKey(Workspace, on_delete=models.CASCADE, related_name="projects") name = models.CharField(max_length=200) description = models.TextField(blank=True) created_by = models.ForeignKey("accounts.User", on_delete=models.PROTECT) created_at = models.DateTimeField(auto_now_add=True) class Meta: indexes = [models.Index(fields=["workspace", "-created_at"])] class Task(models.Model): class Status(models.TextChoices): TODO = "todo" IN_PROGRESS = "in_progress" DONE = "done" project = models.ForeignKey(Project, on_delete=models.CASCADE, related_name="tasks") title = models.CharField(max_length=200) description = models.TextField(blank=True) status = models.CharField(max_length=20, choices=Status.choices, default=Status.TODO) assigned_to = models.ForeignKey("accounts.User", null=True, blank=True, on_delete=models.SET_NULL) due_date = models.DateField(null=True, blank=True) created_at = models.DateTimeField(auto_now_add=True) updated_at = models.DateTimeField(auto_now=True)
Tenant via subdomain
# workspaces/middleware.py class WorkspaceMiddleware: def __init__(self, get_response): self.get_response = get_response def __call__(self, request): host = request.get_host().split(":")[0] subdomain = host.split(".")[0] try: request.workspace = Workspace.objects.get(slug=subdomain) except Workspace.DoesNotExist: request.workspace = None return self.get_response(request) # settings.py MIDDLEWARE += ["workspaces.middleware.WorkspaceMiddleware"] # URLs: acme.api.example.com -> workspace "acme"
Multi-tenant permission
# workspaces/permissions.py from rest_framework.permissions import BasePermission class IsWorkspaceMember(BasePermission): def has_permission(self, request, view): if not request.user.is_authenticated: return False if not request.workspace: return False return Membership.objects.filter( workspace=request.workspace, user=request.user ).exists() class IsWorkspaceAdmin(BasePermission): def has_permission(self, request, view): return Membership.objects.filter( workspace=request.workspace, user=request.user, role__in=["owner", "admin"] ).exists()
Mixin for auto-filtered queryset
class WorkspaceQuerysetMixin: """Auto-filter by current workspace.""" workspace_field = "workspace" def get_queryset(self): qs = super().get_queryset() if self.request.workspace: return qs.filter(**{self.workspace_field: self.request.workspace}) return qs.none() def perform_create(self, serializer): serializer.save(workspace=self.request.workspace, created_by=self.request.user) class ProjectViewSet(WorkspaceQuerysetMixin, viewsets.ModelViewSet): queryset = Project.objects.all() serializer_class = ProjectSerializer permission_classes = [IsAuthenticated, IsWorkspaceMember] def get_queryset(self): return super().get_queryset().select_related("created_by") class TaskViewSet(WorkspaceQuerysetMixin, viewsets.ModelViewSet): queryset = Task.objects.all() serializer_class = TaskSerializer permission_classes = [IsAuthenticated, IsWorkspaceMember] workspace_field = "project__workspace" def get_queryset(self): return super().get_queryset().select_related("project", "assigned_to")
Plan quotas
# workspaces/quotas.py PLAN_LIMITS = { "free": {"max_projects": 3, "max_members": 3, "api_rate": "100/day"}, "pro": {"max_projects": None, "max_members": 20, "api_rate": "10000/day"}, "enterprise": {"max_projects": None, "max_members": None, "api_rate": None}, } def check_project_quota(workspace): limit = PLAN_LIMITS[workspace.plan]["max_projects"] if limit is None: return current = workspace.projects.count() if current >= limit: raise serializers.ValidationError( f"Limit reached: {limit} projects for plan {workspace.plan}. " f"Upgrade to Pro." ) # In the serializer class ProjectSerializer(serializers.ModelSerializer): def create(self, validated_data): check_project_quota(validated_data["workspace"]) return super().create(validated_data)
Plan throttling
class PlanRateThrottle(SimpleRateThrottle): scope = "plan" def allow_request(self, request, view): if not request.workspace: return True rate = PLAN_LIMITS[request.workspace.plan]["api_rate"] if rate is None: return True # enterprise self.num_requests, self.duration = self.parse_rate(rate) self.request = request return super().allow_request(request, view) def get_cache_key(self, request, view): return f"plan_{request.workspace.id}"
Caching responses
Configure the cache
# settings.py - Redis backend CACHES = { "default": { "BACKEND": "django.core.cache.backends.redis.RedisCache", "LOCATION": env("REDIS_URL"), "TIMEOUT": 300, # 5 min default "OPTIONS": { "db": 1, "max_connections": 50, } } } # Quick test with local memory (dev) CACHES = { "default": {"BACKEND": "django.core.cache.backends.locmem.LocMemCache"} }
Full view cache
from django.views.decorators.cache import cache_page from django.utils.decorators import method_decorator class PopularBooksView(generics.ListAPIView): queryset = Book.objects.filter(popular=True) serializer_class = BookSerializer @method_decorator(cache_page(60 * 15)) # 15 min def dispatch(self, *args, **kwargs): return super().dispatch(*args, **kwargs) # OR with vary_on_headers from django.views.decorators.vary import vary_on_headers @method_decorator(cache_page(600), name="dispatch") @method_decorator(vary_on_headers("Authorization"), name="dispatch") class MyView(generics.ListAPIView): # Different cache per token ...
Low-level cache
from django.core.cache import cache class StatsView(APIView): def get(self, request): cache_key = f"stats_{request.user.id}" data = cache.get(cache_key) if data is None: # Expensive computation data = compute_expensive_stats(request.user) cache.set(cache_key, data, timeout=3600) return Response(data) # get_or_set in one line data = cache.get_or_set(f"stats_{user.id}", lambda: compute_stats(user), 3600) # Multi-cache cache.set_many({"a": 1, "b": 2}, timeout=60) values = cache.get_many(["a", "b"]) # Invalidation cache.delete("stats_42") cache.delete_many(["stats_42", "stats_43"]) cache.clear() # clear everything
Cache invalidation via signals
from django.db.models.signals import post_save, post_delete from django.dispatch import receiver @receiver([post_save, post_delete], sender=Book) def invalidate_books_cache(sender, instance, **kwargs): cache.delete("popular_books") cache.delete(f"book_{instance.id}") cache.delete(f"books_by_author_{instance.author_id}")
HTTP cache: ETag
from rest_framework.response import Response import hashlib class BookDetailView(generics.RetrieveAPIView): queryset = Book.objects.all() serializer_class = BookSerializer def retrieve(self, request, *args, **kwargs): book = self.get_object() etag = hashlib.md5(str(book.updated_at).encode()).hexdigest() if request.headers.get("If-None-Match") == etag: return Response(status=304) # Not Modified response = Response(BookSerializer(book).data) response["ETag"] = etag response["Cache-Control"] = "public, max-age=300" return response # Client automatically sends If-None-Match: "abc..." # Server can reply 304 without body -> very fast
Cache-Control per endpoint
from django.views.decorators.cache import cache_control from django.utils.decorators import method_decorator class PublicCatalogView(generics.ListAPIView): queryset = Product.objects.filter(public=True) serializer_class = ProductSerializer @method_decorator(cache_control(public=True, max_age=3600)) def dispatch(self, *args, **kwargs): return super().dispatch(*args, **kwargs) # Header: Cache-Control: public, max-age=3600 # CDN and browser will cache it class UserDataView(APIView): @method_decorator(cache_control(private=True, max_age=60)) def dispatch(self, *args, **kwargs): return super().dispatch(*args, **kwargs) # private = no CDN cache, but browser yes
go-further
This article covers the most useful excerpts — the complete Python Django REST Framework course (8 chapters, 24 lessons, corrected exercises and final project) takes you all the way.
./access-the-full-course free course: Vibe CodingFAQ
How long does it take to learn Python Django REST Framework?
With a structured progression (8 chapters, 24 short and practical lessons), you reach an operational level in a few weeks at 30 to 60 minutes per day. The key is to practice each concept immediately.
Are there any prerequisites?
Basic computer knowledge is enough. If you can use a terminal and read simple code, you are ready.
Where to start concretely?
Reproduce the commands from this article, then follow the complete Python Django REST Framework course: it chains the 24 lessons in order, with exercises and a final project.
./read-also
→ Get started with Portfolio IA SEO Vercel: your first concrete step today→ IA Stripe GitHub SaaS in practice: the code and commands that really matter→ Python Requests APIs explained simply (with diagrams and real code)📬 Want to receive this type of guide every week? Subscribe for free — real code, zero fluff.