DEV Community

Cover image for Django Views: Function-Based vs Class-Based Views — A Complete Production Guide
Alanso Mathew
Alanso Mathew

Posted on

Django Views: Function-Based vs Class-Based Views — A Complete Production Guide

The definitive guide for backend engineers: when to use FBVs, when to reach for CBVs, how to build custom mixin chains, and how production teams ship views that are fast, secure, and maintainable at scale.


Orginally Posted in :https://alansomathewdev.blogspot.com/2026/04/django-views-function-based-vs-class.html

Table of Contents

  1. Introduction
  2. Why This Matters in Production Systems
  3. Core Concepts
  4. Architecture Design
  5. Step-by-Step Implementation
  6. Code Examples
  7. Performance Optimization
  8. Security Best Practices
  9. Common Developer Mistakes
  10. Real Production Use Cases
  11. Conclusion

Introduction

Every Django view has the same job: accept an HTTP request, do some work, and return an HTTP response. Simple. Yet Django provides two fundamentally different ways to build views — and the choice between them is one of the most debated topics in the Django community.

Function-Based Views (FBVs) are plain Python functions. You can read one from top to bottom and understand exactly what it does. No inheritance chains. No method resolution order. No magic class attributes. What you see is what you get.

Class-Based Views (CBVs) are Python classes that inherit from Django's base View or any of its generic subclasses (ListView, CreateView, UpdateView, etc.). They leverage object-oriented programming — inheritance, mixins, method overriding — to eliminate boilerplate, centralise cross-cutting concerns, and build deeply composable view behaviours.

Both are valid. Both are actively used in production. The engineers who write good Django code don't pick one and reject the other — they understand the strengths of each and choose deliberately.

This guide will give you that understanding. We'll cover the internal mechanics of both approaches, build production-grade examples, trace through mixin chains, apply authentication and rate limiting, optimise for high-traffic endpoints, and show you the patterns used in real systems.

By the end, you'll know exactly when to write a function and when to write a class — and why.


Why This Matters in Production Systems

The view layer is where HTTP meets your application logic. It's not just a router — it's the surface area that determines:

  • Security posture: Are authentication checks enforced consistently across all endpoints? Is object-level ownership verified? Are rate limits applied?
  • Testability: Can you test the business logic without an HTTP client? Can you test authentication independently of the view?
  • Maintainability: When you add a new requirement (e.g., "all API views must log requests"), do you update one place or fifty?
  • Performance: Are views triggering N+1 queries? Are they hitting the cache first? Are expensive operations deferred?

FBVs and CBVs make different tradeoffs on all of these dimensions. In a small application with five views, the choice barely matters. In a production application with fifty engineers and five hundred views, it matters enormously.

Django initially only supported function-based views, but they were hard to extend, didn't take advantage of object-oriented programming (OOP) principles, and weren't DRY. This is why Django developers decided to add support for class-based views, which were introduced in Django 1.3.

The practical reality in 2025 production systems: most mature Django codebases use both — FBVs for simple, one-off endpoints where clarity matters, CBVs and generic views for CRUD operations and API resources where DRY matters more. The art is in knowing which to reach for.


Core Concepts

Function-Based Views (FBVs)

A function-based view is a Python callable that accepts an HttpRequest as its first argument and returns an HttpResponse. That's the complete contract.

# The minimal FBV
from django.http import HttpRequest, HttpResponse

def hello(request: HttpRequest) -> HttpResponse:
    return HttpResponse("Hello, world")
Enter fullscreen mode Exit fullscreen mode

Everything else — authentication, query logic, template rendering, form handling — you write explicitly. The control flow is linear and readable.

FBV strengths:

  • Explicit, top-to-bottom control flow — anyone can read it
  • Easy to apply decorators (@login_required, @require_POST)
  • Zero learning curve — it's just a function
  • Easy to test with RequestFactory without understanding class dispatch
  • Ideal for complex, custom logic that doesn't fit a generic pattern

FBV weaknesses:

  • Code duplication across similar views (every CRUD view re-implements the same authentication/pagination logic)
  • HTTP method branching (if request.method == 'POST') adds nesting and visual complexity
  • Sharing behaviour across views requires decorators or utility functions

Class-Based Views (CBVs)

A class-based view is a class that inherits from django.views.View (or a generic subclass). It maps HTTP methods to class methods:

from django.views import View
from django.http import HttpResponse

class HelloView(View):
    def get(self, request):
        return HttpResponse("Hello, GET")

    def post(self, request):
        return HttpResponse("Hello, POST")
Enter fullscreen mode Exit fullscreen mode

Django's URL resolver expects a callable, so CBVs must be registered with .as_view():

# urls.py
path("hello/", HelloView.as_view(), name="hello")
Enter fullscreen mode Exit fullscreen mode

CBV strengths:

  • HTTP method dispatch is built in (get(), post(), put(), etc.)
  • Mixins allow composable, reusable behaviour (authentication, pagination, permissions)
  • Generic views (ListView, CreateView, etc.) eliminate entire categories of boilerplate
  • Behaviour can be overridden at any level of the inheritance chain
  • Central dispatch mechanism makes cross-cutting concerns clean

CBV weaknesses:

  • Implicit behaviour — you need to know what the parent class does
  • Debugging requires understanding method resolution order (MRO)
  • Steeper learning curve, especially with deeply nested generics
  • Over-engineering risk for simple views

Generic Class-Based Views

Django ships with a powerful set of pre-built CBVs for common patterns:

Generic View Purpose Key Override Points
TemplateView Render a template with context get_context_data()
ListView List objects with optional pagination get_queryset(), get_context_data()
DetailView Show one object get_object(), get_queryset()
CreateView Create an object via form form_valid(), get_form_class()
UpdateView Update an object via form form_valid(), get_object()
DeleteView Delete an object get_success_url()
FormView Handle any form (not tied to a model) form_valid(), form_invalid()
RedirectView Redirect to another URL get_redirect_url()

Architecture Design

The CBV Dispatch Mechanism

Understanding how CBVs work internally is the key to using them confidently. When an HTTP request arrives:

HTTP Request → urls.py → HelloView.as_view()
                                    │
                    ┌───────────────┘
                    ▼
         as_view() creates a closure:
         def view(request, *args, **kwargs):
             self = cls(**initkwargs)     ← new instance per request
             self.setup(request, *args, **kwargs)
             return self.dispatch(request, *args, **kwargs)
                    │
                    ▼
         dispatch() maps method to handler:
         handler = getattr(self, request.method.lower(), self.http_method_not_allowed)
         return handler(request, *args, **kwargs)
                    │
                    ▼
         self.get(request) or self.post(request)
Enter fullscreen mode Exit fullscreen mode

A new class instance is created per request — this is critical. CBVs are not singletons. Instance attributes set during a request are not shared between requests.

The Mixin Resolution Chain

Mixins are classes that add behaviour to a CBV without being a full view themselves. Django's MRO (Method Resolution Order) determines which mixin's method wins when multiple mixins define the same method. The rule: leftmost wins.

class OrderDetailView(
    LoginRequiredMixin,          # ← checked 1st in dispatch()
    PermissionRequiredMixin,     # ← checked 2nd
    OwnershipMixin,              # ← checked 3rd (custom)
    CacheMixin,                  # ← applied 4th
    DetailView                   # ← base generic view
):
    ...
Enter fullscreen mode Exit fullscreen mode
MRO resolution for dispatch():

LoginRequiredMixin.dispatch()
    → checks authentication → calls super()
    ↓
PermissionRequiredMixin.dispatch()
    → checks permissions → calls super()
    ↓
OwnershipMixin.dispatch()
    → checks object ownership → calls super()
    ↓
CacheMixin.dispatch()
    → checks cache → calls super() or returns cached response
    ↓
DetailView.dispatch()
    → routes to get() → fetches object → renders template
Enter fullscreen mode Exit fullscreen mode

Each mixin calls super().dispatch() to pass control to the next mixin in the chain. If any mixin short-circuits (e.g., returns a 401 response), the chain stops there.

The Decision Framework

New view needed?
        │
        ├── Webhook handler, one-off logic, complex custom flow?
        │   → Use FBV
        │
        ├── Simple CRUD on a model with standard templates?
        │   → Use Generic CBV (ListView / CreateView / UpdateView / DeleteView)
        │
        ├── API endpoint with standard REST patterns?
        │   → Use DRF APIView or ViewSet (wraps CBV internally)
        │
        ├── Custom logic but benefits from mixin composition?
        │   → Use CBV (inherit from View, add mixins)
        │
        └── Shared behaviour across 5+ views (auth, rate limiting, logging)?
            → Define a Mixin, apply to all CBVs
Enter fullscreen mode Exit fullscreen mode

Step-by-Step Implementation

Step 1: The Same View as FBV and CBV

Let's build a product detail view both ways to feel the difference concretely.

# ── FUNCTION-BASED VIEW ──
from django.shortcuts import render, get_object_or_404
from django.contrib.auth.decorators import login_required
from django.views.decorators.http import require_http_methods

from apps.catalog.models import Product


@login_required
@require_http_methods(["GET"])
def product_detail(request, slug: str):
    product = get_object_or_404(
        Product.objects.select_related("category").prefetch_related("tags"),
        slug=slug,
        status="active",
    )
    return render(request, "catalog/product_detail.html", {
        "product": product,
        "related": Product.objects.filter(category=product.category).exclude(pk=product.pk)[:4],
    })
Enter fullscreen mode Exit fullscreen mode
# ── CLASS-BASED VIEW ──
from django.contrib.auth.mixins import LoginRequiredMixin
from django.views.generic import DetailView
from apps.catalog.models import Product


class ProductDetailView(LoginRequiredMixin, DetailView):
    model          = Product
    template_name  = "catalog/product_detail.html"
    slug_field     = "slug"
    slug_url_kwarg = "slug"

    def get_queryset(self):
        return (
            Product.objects
            .select_related("category")
            .prefetch_related("tags")
            .filter(status="active")
        )

    def get_context_data(self, **kwargs):
        ctx = super().get_context_data(**kwargs)
        ctx["related"] = (
            Product.objects
            .filter(category=self.object.category)
            .exclude(pk=self.object.pk)[:4]
        )
        return ctx
Enter fullscreen mode Exit fullscreen mode

Both views do identical work. The FBV is slightly more readable for someone unfamiliar with DetailView. The CBV is more composable — you can add PermissionRequiredMixin, change pagination, or override object fetching by adding a single line.

Step 2: Registering Views in URLs

# apps/catalog/urls.py
from django.urls import path
from apps.catalog.views import product_detail, ProductDetailView

app_name = "catalog"

urlpatterns = [
    # FBV — just pass the function
    path("products/<slug:slug>/fbv/", product_detail, name="product-detail-fbv"),

    # CBV — must call .as_view()
    path("products/<slug:slug>/", ProductDetailView.as_view(), name="product-detail"),
]
Enter fullscreen mode Exit fullscreen mode

Step 3: Applying Decorators to CBVs

Applying decorators to CBVs requires extra syntax because you're decorating a class, not a function:

from django.utils.decorators import method_decorator
from django.views.decorators.cache import cache_page
from django.views.decorators.vary import vary_on_headers
from django.contrib.auth.mixins import LoginRequiredMixin
from django.views.generic import DetailView


# Option 1: method_decorator on the class (applies to dispatch)
@method_decorator(cache_page(60 * 15), name="dispatch")
@method_decorator(vary_on_headers("Accept-Language"), name="dispatch")
class ProductDetailView(LoginRequiredMixin, DetailView):
    ...

# Option 2: method_decorator on a specific method
class ProductDetailView(LoginRequiredMixin, DetailView):
    @method_decorator(cache_page(60 * 15))
    def get(self, request, *args, **kwargs):
        return super().get(request, *args, **kwargs)

# Option 3 (preferred): Use mixins instead of decorators on CBVs
# LoginRequiredMixin is cleaner than @method_decorator(login_required, name='dispatch')
Enter fullscreen mode Exit fullscreen mode

Code Examples

Production FBV: Webhook Handler

FBVs are the right choice for complex, stateful handlers that don't fit any generic pattern — like webhook receivers:

# apps/payments/views.py
import hashlib
import hmac
import json
import logging

from django.conf import settings
from django.http import HttpRequest, HttpResponse, HttpResponseBadRequest
from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.http import require_POST

from apps.payments.tasks import process_stripe_event

logger = logging.getLogger(__name__)


@csrf_exempt
@require_POST
def stripe_webhook(request: HttpRequest) -> HttpResponse:
    """
    Receives Stripe webhook events and dispatches to Celery for processing.

    Why FBV? This view has:
    - CSRF exemption (can't use mixin for this cleanly)
    - Signature verification on raw bytes (before parsing)
    - Complex branching on event type
    - No "object" to fetch — pure event processing

    All of these are cleaner in a flat function than a class.
    """
    payload = request.body
    sig_header = request.META.get("HTTP_STRIPE_SIGNATURE", "")
    webhook_secret = settings.STRIPE_WEBHOOK_SECRET

    # Verify Stripe signature BEFORE parsing JSON
    # Use raw bytes — never decode before verification
    expected_sig = hmac.new(
        webhook_secret.encode("utf-8"),
        payload,
        hashlib.sha256,
    ).hexdigest()

    if not hmac.compare_digest(expected_sig, sig_header.split("=")[-1]):
        logger.warning("Stripe webhook signature mismatch")
        return HttpResponseBadRequest("Invalid signature")

    try:
        event = json.loads(payload)
    except json.JSONDecodeError:
        return HttpResponseBadRequest("Invalid JSON")

    event_type = event.get("type", "")
    logger.info("Stripe webhook received: %s", event_type)

    # Dispatch to Celery — never process synchronously in a webhook handler
    process_stripe_event.delay(event)

    return HttpResponse(status=200)
Enter fullscreen mode Exit fullscreen mode

Production CBV: Full CRUD with Mixin Chain

# apps/orders/views.py
from django.contrib.auth.mixins import LoginRequiredMixin, PermissionRequiredMixin
from django.contrib import messages
from django.urls import reverse_lazy
from django.views.generic import (
    ListView, DetailView, CreateView, UpdateView, DeleteView
)
from apps.orders.models import Order
from apps.orders.forms import OrderCreateForm, OrderUpdateForm
from apps.orders.mixins import OwnershipRequiredMixin, RateLimitMixin


class OrderListView(LoginRequiredMixin, ListView):
    """
    Lists orders for the authenticated user.
    Non-staff users only see their own orders.
    Staff users see all orders.
    """
    template_name      = "orders/list.html"
    context_object_name = "orders"
    paginate_by        = 20

    def get_queryset(self):
        qs = Order.objects.select_related("user").prefetch_related("items__product")
        if not self.request.user.is_staff:
            qs = qs.filter(user=self.request.user)
        return qs.order_by("-created_at")


class OrderDetailView(LoginRequiredMixin, OwnershipRequiredMixin, DetailView):
    """
    Shows order details. OwnershipRequiredMixin enforces that
    the requesting user owns this order (unless staff).
    """
    model              = Order
    template_name      = "orders/detail.html"
    context_object_name = "order"

    def get_queryset(self):
        return Order.objects.select_related("user", "shipping_address").prefetch_related("items__product")


class OrderCreateView(LoginRequiredMixin, RateLimitMixin, CreateView):
    """
    Creates a new order. RateLimitMixin prevents order flooding.
    """
    form_class    = OrderCreateForm
    template_name = "orders/create.html"
    success_url   = reverse_lazy("orders:list")
    rate_limit    = "10/hour"   # ← read by RateLimitMixin

    def form_valid(self, form):
        form.instance.user = self.request.user
        messages.success(self.request, "Order placed successfully.")
        return super().form_valid(form)


class OrderUpdateView(LoginRequiredMixin, OwnershipRequiredMixin, UpdateView):
    model         = Order
    form_class    = OrderUpdateForm
    template_name = "orders/update.html"

    def get_success_url(self):
        return reverse_lazy("orders:detail", kwargs={"pk": self.object.pk})

    def form_valid(self, form):
        messages.success(self.request, "Order updated.")
        return super().form_valid(form)


class OrderDeleteView(LoginRequiredMixin, OwnershipRequiredMixin, DeleteView):
    model       = Order
    template_name = "orders/confirm_delete.html"
    success_url   = reverse_lazy("orders:list")

    def form_valid(self, form):
        messages.success(self.request, f"Order #{self.object.pk} deleted.")
        return super().form_valid(form)
Enter fullscreen mode Exit fullscreen mode

Custom Mixins: Reusable Cross-Cutting Concerns

# apps/orders/mixins.py
import hashlib
import time
from django.core.cache import cache
from django.http import JsonResponse, HttpResponseForbidden
from django.contrib.auth.mixins import AccessMixin


class OwnershipRequiredMixin(AccessMixin):
    """
    Enforces that request.user owns the object being accessed.
    Staff users bypass this check (they can access anything).

    Place AFTER LoginRequiredMixin in MRO — user must be authenticated first.
    """
    ownership_field = "user"  # ← field on the model that holds the owner FK

    def dispatch(self, request, *args, **kwargs):
        response = super().dispatch(request, *args, **kwargs)

        # Staff bypass
        if request.user.is_staff:
            return response

        # Fetch the object if not already fetched
        obj = getattr(self, "object", None)
        if obj is None:
            obj = self.get_object()

        owner = getattr(obj, self.ownership_field, None)
        owner_id = getattr(owner, "id", owner)  # handle FK or plain id

        if owner_id != request.user.id:
            return HttpResponseForbidden("You do not have permission to access this resource.")

        return response


class RateLimitMixin:
    """
    Applies a simple sliding-window rate limit per authenticated user.
    Reads the rate from self.rate_limit in the format "N/period"
    where period is "minute", "hour", or "day".

    Example: rate_limit = "10/hour"
    """
    rate_limit = "100/hour"

    def _parse_rate(self):
        limit, period = self.rate_limit.split("/")
        period_seconds = {"second": 1, "minute": 60, "hour": 3600, "day": 86400}
        return int(limit), period_seconds.get(period, 3600)

    def dispatch(self, request, *args, **kwargs):
        if request.user.is_authenticated:
            limit, window = self._parse_rate()
            bucket = int(time.time() // window)
            key = f"rl:{request.user.id}:{self.__class__.__name__}:{bucket}"
            count = cache.get(key, 0)

            if count >= limit:
                return JsonResponse(
                    {"error": "Rate limit exceeded. Please try again later."},
                    status=429,
                )
            cache.set(key, count + 1, timeout=window)

        return super().dispatch(request, *args, **kwargs)


class AuditLogMixin:
    """
    Logs every successful write operation (POST, PUT, PATCH, DELETE)
    for audit trail purposes.
    """
    def dispatch(self, request, *args, **kwargs):
        response = super().dispatch(request, *args, **kwargs)

        if request.method in ("POST", "PUT", "PATCH", "DELETE") and response.status_code < 400:
            import logging
            logger = logging.getLogger("audit")
            logger.info(
                "audit_log",
                extra={
                    "user_id":    request.user.id if request.user.is_authenticated else None,
                    "method":     request.method,
                    "path":       request.path,
                    "view":       self.__class__.__name__,
                    "status":     response.status_code,
                    "ip":         request.META.get("HTTP_X_FORWARDED_FOR", request.META.get("REMOTE_ADDR")),
                },
            )

        return response
Enter fullscreen mode Exit fullscreen mode

Django REST Framework: CBVs for API Views

In production APIs built on DRF, CBVs are near-universal. The APIView pattern gives you HTTP method dispatch, authentication, permission enforcement, and throttling all in one place:

# apps/api/views.py
from rest_framework.views import APIView
from rest_framework.response import Response
from rest_framework import status
from rest_framework.permissions import IsAuthenticated
from rest_framework.throttling import UserRateThrottle

from apps.orders.serializers import OrderDetailSerializer, CreateOrderSerializer
from apps.orders.selectors import get_order_for_user, get_orders_for_user
from apps.orders.services import OrderService


class OrderListCreateAPIView(APIView):
    """
    GET  /api/orders/     → list user's orders
    POST /api/orders/     → create a new order

    Authentication, throttling, and pagination are class-level concerns.
    Business logic lives in selectors and services — not the view.
    """
    permission_classes = [IsAuthenticated]
    throttle_classes   = [UserRateThrottle]

    def get(self, request):
        orders = get_orders_for_user(user_id=request.user.id)
        serializer = OrderDetailSerializer(orders, many=True)
        return Response({
            "count": orders.count(),
            "results": serializer.data,
        })

    def post(self, request):
        serializer = CreateOrderSerializer(data=request.data)
        serializer.is_valid(raise_exception=True)
        order = OrderService.create_order(
            user=request.user,
            **serializer.validated_data,
        )
        return Response(
            {"id": order.id, "status": order.status},
            status=status.HTTP_201_CREATED,
        )


class OrderDetailAPIView(APIView):
    """
    GET    /api/orders/<pk>/  → retrieve order
    PUT    /api/orders/<pk>/  → full update
    DELETE /api/orders/<pk>/  → cancel order
    """
    permission_classes = [IsAuthenticated]

    def get_object(self, pk: int):
        return get_order_for_user(user=self.request.user, order_id=pk)

    def get(self, request, pk: int):
        order = self.get_object(pk)
        return Response(OrderDetailSerializer(order).data)

    def delete(self, request, pk: int):
        order = self.get_object(pk)
        OrderService.cancel_order(order)
        return Response(status=status.HTTP_204_NO_CONTENT)
Enter fullscreen mode Exit fullscreen mode

FBV with Decorator Stack: Maximum Explicit Control

# apps/reports/views.py
import csv
from django.contrib.auth.decorators import login_required, permission_required
from django.views.decorators.http import require_GET
from django.views.decorators.cache import cache_page
from django.views.decorators.vary import vary_on_cookie
from django.http import StreamingHttpResponse

from apps.orders.models import Order


class EchoWriter:
    def write(self, value):
        return value


@login_required
@permission_required("orders.view_order", raise_exception=True)
@require_GET
@cache_page(60 * 5)            # 5-minute page cache
@vary_on_cookie                 # Separate cache per session
def export_orders_csv(request):
    """
    Streams a large orders CSV to the response.
    FBV is the right choice here — this is a one-off export view
    with specific HTTP method requirements, multiple decorators,
    and custom streaming behaviour that CBVs don't simplify.
    """
    writer = csv.writer(EchoWriter())

    def generate():
        yield writer.writerow(["ID", "User", "Status", "Total", "Date"])
        qs = (
            Order.objects
            .filter(user=request.user)
            .select_related("user")
            .only("id", "user__email", "status", "total", "created_at")
            .iterator(chunk_size=1000)
        )
        for order in qs:
            yield writer.writerow([
                order.id, order.user.email, order.status,
                str(order.total), order.created_at.date(),
            ])

    response = StreamingHttpResponse(generate(), content_type="text/csv")
    response["Content-Disposition"] = 'attachment; filename="my_orders.csv"'
    return response
Enter fullscreen mode Exit fullscreen mode

Performance Optimization

1. Override get_queryset() in Generic CBVs — Never Use queryset = Directly

# BAD — queryset is evaluated once at class definition time
class ProductListView(ListView):
    queryset = Product.objects.filter(status="active")  # ← evaluated at import!

# GOOD — get_queryset() is called per request, allowing dynamic filtering
class ProductListView(ListView):
    def get_queryset(self):
        qs = Product.objects.filter(status="active").select_related("category")
        if q := self.request.GET.get("q"):
            qs = qs.filter(name__icontains=q)
        return qs
Enter fullscreen mode Exit fullscreen mode

2. Cache at the View Layer with cache_page

from django.utils.decorators import method_decorator
from django.views.decorators.cache import cache_page
from django.views.decorators.vary import vary_on_headers


# FBV
@cache_page(60 * 15)
@vary_on_headers("Accept-Language")
def product_list(request):
    ...


# CBV — apply to dispatch or specific methods
@method_decorator(cache_page(60 * 15), name="dispatch")
class ProductListView(ListView):
    ...
Enter fullscreen mode Exit fullscreen mode

3. Use only() in View QuerySets for List Views

List views often only display a subset of model fields. Don't load what you don't need:

class OrderListView(LoginRequiredMixin, ListView):
    paginate_by = 25

    def get_queryset(self):
        return (
            Order.objects
            .filter(user=self.request.user)
            # Load only the columns actually used in the template
            .only("id", "status", "total", "created_at")
            .order_by("-created_at")
        )
Enter fullscreen mode Exit fullscreen mode

4. Optimise Pagination for Large Datasets

Django's built-in paginate_by uses COUNT(*) to determine total pages, which is expensive on large tables. For high-traffic list views, use cursor-based pagination instead:

class OrderListView(LoginRequiredMixin, ListView):
    paginate_by = 20

    def get_queryset(self):
        qs = Order.objects.filter(user=self.request.user).order_by("-created_at")

        # Cursor-based: use the last seen ID to avoid COUNT(*)
        after_id = self.request.GET.get("after")
        if after_id:
            qs = qs.filter(id__lt=after_id)

        return qs[:self.paginate_by + 1]   # fetch one extra to detect next page

    def get_context_data(self, **kwargs):
        ctx = super().get_context_data(**kwargs)
        orders = list(ctx["orders"])
        ctx["has_next"] = len(orders) > self.paginate_by
        ctx["orders"] = orders[:self.paginate_by]
        if orders:
            ctx["next_cursor"] = orders[-1].id
        return ctx
Enter fullscreen mode Exit fullscreen mode

5. Use get_object() Caching in CBVs

get_object() in CBVs queries the database every time it's called. Cache it:

from django.utils.functional import cached_property


class OrderDetailView(LoginRequiredMixin, OwnershipRequiredMixin, DetailView):
    model = Order

    @cached_property
    def object(self):
        """
        Override object as a cached_property to avoid multiple DB hits.
        get_object() is called in OwnershipRequiredMixin.dispatch()
        AND in DetailView.get() — without caching, that's two queries.
        """
        return self.get_object()
Enter fullscreen mode Exit fullscreen mode

Security Best Practices

1. Always Put LoginRequiredMixin First

# CORRECT — MRO ensures authentication check happens before anything else
class OrderDetailView(LoginRequiredMixin, OwnershipRequiredMixin, DetailView):
    ...

# WRONG — OwnershipRequiredMixin might call get_object() before auth check
class OrderDetailView(OwnershipRequiredMixin, LoginRequiredMixin, DetailView):
    ...
Enter fullscreen mode Exit fullscreen mode

Best practices prioritise mixins over @method_decorator(login_required) for CBVs due to cleaner inheritance and MRO compliance.

2. Object-Level Authorisation — Never Trust URL Parameters Alone

# DANGEROUS — any authenticated user can access any order by ID
class OrderDetailView(LoginRequiredMixin, DetailView):
    model = Order
    # ← No ownership check! IDOR vulnerability.

# SAFE — always scope get_queryset to the authenticated user
class OrderDetailView(LoginRequiredMixin, DetailView):
    def get_queryset(self):
        # Scoping here means get_object() raises 404 for orders not owned by this user
        return Order.objects.filter(user=self.request.user)
Enter fullscreen mode Exit fullscreen mode

3. CSRF Protection for Custom API Views

from django.utils.decorators import method_decorator
from django.views.decorators.csrf import csrf_exempt
from django.views import View


# Webhook handlers that receive external POST requests must be CSRF-exempt
# But document WHY — missing CSRF is a common security review finding
@method_decorator(csrf_exempt, name="dispatch")
class StripeWebhookView(View):
    """
    CSRF exempt: requests come from Stripe servers, not browsers.
    Security is handled by Stripe's HMAC signature verification instead.
    """
    def post(self, request):
        ...
Enter fullscreen mode Exit fullscreen mode

4. Set raise_exception=True for API Views

For API views, redirecting unauthenticated users to a login page makes no sense. Raise a 403 instead:

from django.contrib.auth.mixins import LoginRequiredMixin
from django.views.generic import DetailView


class APIOrderDetailView(LoginRequiredMixin, DetailView):
    # Return 403 Forbidden instead of redirecting to login page
    # Essential for AJAX/API endpoints consumed by frontend apps
    raise_exception = True

    def handle_no_permission(self):
        from django.http import JsonResponse
        return JsonResponse({"error": "Authentication required."}, status=401)
Enter fullscreen mode Exit fullscreen mode

5. Rate Limiting as a Mixin (Not Just Middleware)

Middleware applies globally. View-level rate limiting lets you set different limits for different endpoints:

class OrderCreateView(LoginRequiredMixin, RateLimitMixin, CreateView):
    rate_limit = "5/hour"      # ← strict: orders are high-value operations

class ProductSearchView(LoginRequiredMixin, RateLimitMixin, ListView):
    rate_limit = "300/minute"  # ← lenient: search is read-only
Enter fullscreen mode Exit fullscreen mode

Common Developer Mistakes

❌ Mistake 1: Putting Business Logic in the View

# BAD — the view does everything: validates, queries, transforms, emails
class OrderCreateView(LoginRequiredMixin, CreateView):
    def form_valid(self, form):
        product = Product.objects.get(id=form.cleaned_data["product_id"])
        if product.stock < form.cleaned_data["quantity"]:
            form.add_error(None, "Insufficient stock")
            return self.form_invalid(form)
        order = form.save(commit=False)
        order.user = self.request.user
        order.total = product.price * form.cleaned_data["quantity"]
        order.save()
        product.stock -= form.cleaned_data["quantity"]
        product.save()
        send_mail("Order confirmed", ..., [self.request.user.email])
        return super().form_valid(form)

# GOOD — view delegates to a service; logic is testable without HTTP
class OrderCreateView(LoginRequiredMixin, CreateView):
    def form_valid(self, form):
        order = OrderService.create_order(
            user=self.request.user,
            **form.cleaned_data,
        )
        return HttpResponseRedirect(reverse("orders:detail", kwargs={"pk": order.pk}))
Enter fullscreen mode Exit fullscreen mode

❌ Mistake 2: Using queryset = as a Class Attribute in CBVs

# BAD — evaluated ONCE at class definition time, not per request
class ProductListView(ListView):
    queryset = Product.objects.filter(status="active")
    # Django caches this; changes to the DB not reflected without restart

# GOOD — evaluated per request
class ProductListView(ListView):
    def get_queryset(self):
        return Product.objects.filter(status="active")
Enter fullscreen mode Exit fullscreen mode

❌ Mistake 3: Forgetting .as_view() in URLs

# BAD — Django will raise a TypeError when the URL is hit
path("orders/", OrderListView, name="order-list")    # ← missing .as_view()

# GOOD
path("orders/", OrderListView.as_view(), name="order-list")
Enter fullscreen mode Exit fullscreen mode

❌ Mistake 4: Wrong Mixin Order

# BAD — RateLimitMixin tries to access self.object before LoginRequiredMixin checks auth
class OrderDetailView(RateLimitMixin, LoginRequiredMixin, DetailView):
    ...

# GOOD — authentication first, then rate limiting, then ownership, then logic
class OrderDetailView(LoginRequiredMixin, RateLimitMixin, OwnershipRequiredMixin, DetailView):
    ...
Enter fullscreen mode Exit fullscreen mode

❌ Mistake 5: Using FBVs for Everything Just to Avoid Learning CBVs

# BAD — 5 near-identical CRUD FBVs with copy-pasted logic
def product_list(request): ...
def product_detail(request, pk): ...
def product_create(request): ...
def product_update(request, pk): ...
def product_delete(request, pk): ...

# GOOD — 5 CBVs that each override only what differs
class ProductListView(LoginRequiredMixin, ListView): ...
class ProductDetailView(LoginRequiredMixin, DetailView): ...
class ProductCreateView(LoginRequiredMixin, CreateView): ...
class ProductUpdateView(LoginRequiredMixin, UpdateView): ...
class ProductDeleteView(LoginRequiredMixin, DeleteView): ...
Enter fullscreen mode Exit fullscreen mode

Real Production Use Cases

Use Case 1: SaaS Dashboard — Tenant-Scoped CBV Mixin

# common/mixins.py
class TenantScopedMixin:
    """
    Scopes all querysets to the current tenant.
    Attach to every CBV in a multi-tenant SaaS application.
    The tenant is resolved by TenantResolutionMiddleware and attached to request.tenant.
    """
    def get_queryset(self):
        qs = super().get_queryset()
        if hasattr(self.request, "tenant"):
            qs = qs.filter(tenant=self.request.tenant)
        return qs


# Usage: one mixin, consistent scoping everywhere
class ProjectListView(LoginRequiredMixin, TenantScopedMixin, ListView):
    model = Project
    paginate_by = 20

class ProjectDetailView(LoginRequiredMixin, TenantScopedMixin, DetailView):
    model = Project
Enter fullscreen mode Exit fullscreen mode

Use Case 2: E-Commerce — Layered Mixin Chain for Checkout

class CheckoutView(
    LoginRequiredMixin,          # Must be authenticated
    RateLimitMixin,              # Max 5 checkouts per hour
    AuditLogMixin,               # Every checkout logged for compliance
    TemplateView,
):
    template_name = "checkout/index.html"
    rate_limit    = "5/hour"
    raise_exception = True       # API consumer — return 403, not redirect

    def get_context_data(self, **kwargs):
        ctx = super().get_context_data(**kwargs)
        ctx["cart"] = CartService.get_or_create(self.request.user)
        ctx["payment_intent"] = PaymentService.create_intent(ctx["cart"])
        return ctx

    def post(self, request, *args, **kwargs):
        serializer = CheckoutSerializer(data=request.POST)
        serializer.is_valid(raise_exception=True)
        order = OrderService.create_from_cart(
            user=request.user,
            **serializer.validated_data,
        )
        return redirect(reverse("orders:confirmation", kwargs={"pk": order.pk}))
Enter fullscreen mode Exit fullscreen mode

Use Case 3: REST API — FBVs for Complex Custom Endpoints

Not every API endpoint maps cleanly to a resource. For operations — "cancel order", "resend confirmation", "bulk-archive products" — FBVs with DRF decorators are often cleaner:

from rest_framework.decorators import api_view, permission_classes, throttle_classes
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from rest_framework.throttling import UserRateThrottle


@api_view(["POST"])
@permission_classes([IsAuthenticated])
@throttle_classes([UserRateThrottle])
def cancel_order(request, pk: int):
    """
    POST /api/orders/<pk>/cancel/

    This is an operation, not a resource. It doesn't fit
    ListView/DetailView/CreateView. FBV with DRF decorators
    is the cleanest approach.
    """
    from apps.orders.selectors import get_order_for_user
    from apps.orders.services import OrderService

    order = get_order_for_user(user=request.user, order_id=pk)
    OrderService.cancel_order(order)
    return Response({"status": "cancelled"})
Enter fullscreen mode Exit fullscreen mode

Conclusion

Function-based views and class-based views are not competing philosophies — they are complementary tools with different strengths.

Use FBVs when:

  • The view logic is unique and doesn't generalise
  • You need maximum clarity for complex, custom flows
  • You're handling webhooks, streaming responses, or operations (not resources)
  • You want to stack decorators without worrying about MRO

Use CBVs when:

  • You're implementing standard CRUD patterns on a model
  • You need to share behaviour across multiple views via mixins
  • You want authentication, rate limiting, and audit logging applied consistently at the class level
  • You're building a REST API where HTTP method dispatch is structural

The most important principle, regardless of which you choose: keep views thin. A view's job is HTTP. It should accept a request, call a service or selector, and return a response. Business logic lives in services. Database logic lives in selectors and QuerySets. Cross-cutting concerns live in middleware or mixins.

A thin view is readable regardless of whether it's a function or a class. A fat view is hard to maintain regardless of which style you chose.

Build views that do one thing. Compose them with mixins when you need to. Choose the form that makes the intent clearest — and change your mind when the requirements change.


Further Reading


Written by a Python backend engineer building production Django systems. Topics: Django views, FBV, CBV, generic views, mixins, LoginRequiredMixin, PermissionRequiredMixin, rate limiting, CSRF, DRF APIView, production patterns.

Top comments (0)