Django CRUD
in Action

Building a full Create · Read · Update · Delete workflow using Class-Based Views to write less, do more.

CRUD is the backbone of almost every web application. Django gives you two paths to implement it: Function-Based Views (FBV) — explicit and readable — and Class-Based Views (CBV) — concise, reusable, and DRY.

Understanding Views in Django

In Django, a view is the layer that receives an HTTP request and returns an HTTP response. It's where your application logic lives — querying the database, processing form data, deciding what template to render. Django offers two distinct approaches to writing views:

Function-Based Views (FBV)

An FBV is a plain Python function that takes an HttpRequest as its first argument and returns an HttpResponse. You have complete, explicit control over every step: checking the HTTP method, instantiating forms, validating data, saving objects, and choosing what to render. This makes FBVs easy to understand — the entire flow is visible in one function — but it also means you rewrite the same patterns (method checking, form handling, error rendering) in every view.

Class-Based Views (CBV)

A CBV is a Python class that inherits from django.views.View (or one of Django's generic views like ListView, CreateView, etc.). Instead of a single function, the logic is split into methods that map to HTTP verbsget(), post(), put(), etc. Django's generic CBVs go further: they encapsulate common patterns (listing objects, rendering forms, saving models) into reusable, configurable classes. You provide a few attributes (model, fields, template_name) and the CBV handles the rest.

FBV vs CBV — At a Glance

AspectFBV (Function-Based)CBV (Class-Based)
StructureA single function with if/else branchesA class with separate methods per HTTP verb
BoilerplateHigh — you write form handling, method checks, save + redirect logic every timeLow — generic views handle all of this with a few attributes
ReusabilityLimited — extracting shared logic requires decorators or helper functionsHigh — use inheritance and mixins to compose behaviour
ReadabilityExcellent for simple views — the entire flow is in one placeExcellent for standard patterns — but can feel "magic" until you understand the method chain
ExtensibilityUse decorators (@login_required)Use mixins (LoginRequiredMixin) — stackable and order-sensitive
Learning curveLow — just Python functionsModerate — requires understanding inheritance, MRO, and which methods to override
🔑 When to use which? Use FBVs for one-off views with custom logic that doesn't fit a standard pattern (e.g., a dashboard aggregating multiple models, a webhook handler). Use CBVs for standard CRUD operations and any view that follows a repeatable pattern — they eliminate boilerplate and are easy to extend with mixins. When in doubt, start with a CBV; you can always override methods to customize behaviour.
📦 What we'll build A Book management app — list all books, view one book, create, edit, and delete. A classic CRUD app you can adapt to any model.

The CRUD Map

Every operation maps to an HTTP verb, a URL pattern, and a CBV:

OperationHTTP MethodURL ExampleCBV
READ list GET/books/ ListView
READ one GET/books/3/ DetailView
CREATE GET + POST/books/new/ CreateView
UPDATE GET + POST/books/3/edit/ UpdateView
DELETE GET + POST/books/3/delete/ DeleteView

Request → Response Flow

BrowserHTTP Request
urls.pyRoute match
CBVdispatch()
Templaterender()
BrowserHTML Response

How CBVs Work Under the Hood

When you write path('books/', BookListView.as_view()) in your URL configuration, here's what actually happens:

1. as_view() is called at import time. It returns a plain function (a closure) that Django's URL dispatcher can call — because Django's routing expects a callable, not a class. This function creates a new instance of the CBV for every incoming request, ensuring views are stateless.

2. dispatch() routes to the right method. When a request arrives, the closure calls dispatch(request, *args, **kwargs). This method inspects request.method — if it's a GET, it calls self.get(); if it's a POST, it calls self.post(); and so on. If the HTTP method isn't supported, Django returns 405 Method Not Allowed automatically.

3. The method chain takes over. For a generic CBV like CreateView, the get() method renders an empty form, while the post() method validates the form, saves the object, and redirects. Each step is a separate method you can override — get_queryset(), get_form(), form_valid(), get_context_data() — giving you fine-grained control without rewriting entire views.

ℹ️ The Inheritance Chain Django's generic CBVs use deep inheritance. For example, CreateView inherits from BaseCreateViewModelFormMixinFormMixinContextMixinView. Each layer adds specific capabilities (form handling, context injection, dispatch logic). You don't need to memorize this chain — just know that every attribute and method you set on your view overrides something in this chain.
💡 Why CBVs? A function-based CreateView is typically 20–30 lines. The CBV equivalent is 4 lines. Django handles form rendering, validation, saving, and redirects automatically.

The Model We'll Use

books/models.py Python
from django.db import models

class Book(models.Model):
    title   = models.CharField(max_length=200)
    author  = models.CharField(max_length=100)
    pages   = models.IntegerField()
    pub_date= models.DateField()

    def __str__(self):
        return self.title

    def get_absolute_url(self):
        from django.urls import reverse
        return reverse('book-detail', kwargs={'pk': self.pk})
🔑 get_absolute_url() CBVs like CreateView and UpdateView redirect to get_absolute_url() by default after a successful save — so always define it on your model!

Project Setup

Getting the Django project and app ready for our CRUD workflow.

Create the Project

1
Create & activate a virtual environment
terminalbash
python -m venv venv
source venv/bin/activate       # Windows: venv\Scripts\activate
pip install django
2
Create project and app
terminalbash
django-admin startproject mysite .
python manage.py startapp books
3
Register the app in settings
mysite/settings.pyPython
INSTALLED_APPS = [
    # ... built-ins ...
    'books',   # ← add this
]
4
Define the model, then migrate
terminalbash
python manage.py makemigrations
python manage.py migrate
python manage.py createsuperuser   # optional
5
Wire up the books URLs in the project
mysite/urls.pyPython
from django.urls import path, include

urlpatterns = [
    path('books/', include('books.urls')),
]

Recommended File Structure

project treebash
mysite/
├── mysite/
│   ├── settings.py
│   └── urls.py
└── books/
    ├── migrations/
    ├── templates/
    │   └── books/
    │       ├── book_list.html
    │       ├── book_detail.html
    │       ├── book_form.html
    │       └── book_confirm_delete.html
    ├── models.py
    ├── views.py
    └── urls.py

FBV → CBV

See exactly how a Function-Based View transforms into a Class-Based View — same behaviour, far less code.

ℹ️ Toggle between implementations Both code blocks below produce identical behaviour. The CBV version delegates boilerplate to Django's generic views.

When we say a CBV "delegates boilerplate," we mean something specific: all the repetitive logic you write in every FBV — checking request.method, creating a form instance, calling is_valid(), saving, redirecting on success, re-rendering on error — is already implemented in the generic CBV's method chain. Your job shifts from writing logic to configuring behaviour via class attributes and targeted method overrides.

What stays the same between FBV and CBV? The URL wiring (you still use path() in urls.py), the template rendering (same templates, same context variables), and the HTTP contract (same request in, same response out). What changes is the structure: procedural if/else becomes declarative configuration.

The List View (READ all)

books/views.py (FBV)Python
from django.shortcuts import render
from .models import Book

def book_list(request):
    books = Book.objects.all()
    return render(request, 'books/book_list.html', {'books': books})
books/views.py (CBV)Python
from django.views.generic import ListView
from .models import Book

class BookListView(ListView):
    model = Book
    # auto template: books/book_list.html
    # auto context:  object_list  (or book_list)

The Create View (CREATE)

books/views.py (FBV)Python
from django.shortcuts import render, redirect
from .forms import BookForm

def book_create(request):
    if request.method == 'POST':
        form = BookForm(request.POST)
        if form.is_valid():
            book = form.save()
            return redirect(book)
    else:
        form = BookForm()
    return render(request, 'books/book_form.html', {'form': form})
books/views.py (CBV)Python
from django.views.generic.edit import CreateView
from .models import Book

class BookCreateView(CreateView):
    model  = Book
    fields = ['title', 'author', 'pages', 'pub_date']
    # GET:  renders book_form.html with empty form
    # POST: validates, saves, redirects to get_absolute_url()
🏆 The Pattern The FBV must manually: instantiate the form twice, check the method, call is_valid(), save, redirect, and render. The CBV does all of this automatically when you set model and fields.

CBV Deep-Dive

Every generic CBV you need for a full CRUD workflow, with all the knobs you can turn.

ListView
GET /books/
Fetches all objects and passes them to a template as object_list (or book_list).
DetailView
GET /books/<pk>/
Fetches one object by pk or slug. Raises 404 automatically if not found.
CreateView
GET + POST /books/new/
Renders a ModelForm, validates on POST, saves, and redirects to get_absolute_url().
UpdateView
GET + POST /books/<pk>/edit/
Like CreateView, but pre-fills the form with existing data. Same template (book_form.html) by default.
DeleteView
GET + POST /books/<pk>/delete/
GET shows a confirmation page. POST deletes and redirects to success_url.

ListView — Full Example

ListView inherits from MultipleObjectMixin and BaseListView. Its job is simple: fetch a queryset and pass it to a template. Key attributes include model (which model to query), queryset (a custom queryset to use instead), template_name, context_object_name (defaults to object_list), paginate_by (automatic pagination), and ordering. Override get_queryset() to add filtering or search — this is the most commonly overridden method on ListView.

books/views.pyPython
from django.views.generic import ListView

class BookListView(ListView):
    model               = Book
    template_name       = 'books/book_list.html'   # default
    context_object_name = 'books'                   # default: object_list
    paginate_by         = 10                        # optional pagination
    ordering            = ['-pub_date']             # newest first

    def get_queryset(self):
        # override to filter — e.g., search by query param
        qs = super().get_queryset()
        q  = self.request.GET.get('q')
        if q:
            qs = qs.filter(title__icontains=q)
        return qs

DetailView — Full Example

DetailView inherits from SingleObjectMixin and BaseDetailView. It fetches a single object by pk (or slug if configured) and passes it to the template as object (or a name derived from the model, e.g., book). If the object doesn't exist, Django automatically raises an Http404 — no manual error handling needed. Override get_context_data() to inject extra information into the template, such as related objects or computed values.

books/views.pyPython
from django.views.generic import DetailView

class BookDetailView(DetailView):
    model               = Book
    template_name       = 'books/book_detail.html'  # default
    context_object_name = 'book'                     # default: object

    def get_context_data(self, **kwargs):
        # add extra context variables to the template
        ctx = super().get_context_data(**kwargs)
        ctx['related'] = Book.objects.filter(
            author=self.object.author
        ).exclude(pk=self.object.pk)
        return ctx

CreateView — Full Example

CreateView inherits from ModelFormMixin and ProcessFormView. On GET, it renders an empty ModelForm; on POST, it validates the submitted data, creates the object, and redirects. You must provide either fields (a list of field names) or form_class (a custom ModelForm) — never both. The most common override is form_valid(), where you can attach extra data before saving — for example, setting the object's owner to self.request.user.

books/views.pyPython
from django.views.generic.edit import CreateView
from django.urls import reverse_lazy

class BookCreateView(CreateView):
    model         = Book
    fields        = ['title', 'author', 'pages', 'pub_date']
    # OR: form_class = BookModelForm   (to use a custom ModelForm)

    def form_valid(self, form):
        # hook: runs after valid submission, before redirect
        # e.g., attach logged-in user: form.instance.owner = self.request.user
        return super().form_valid(form)

UpdateView — Full Example

UpdateView is nearly identical to CreateView — it shares the same parent classes and the same template (book_form.html) by default. The key difference: before rendering the form, it calls get_object() to fetch the existing instance by pk, then pre-populates the form fields with the current data. On successful POST, it saves the updated object and redirects to get_absolute_url() (or success_url if set). Because both Create and Update use the same template, the template can use {% if object %} to distinguish between "New" and "Edit" modes.

books/views.pyPython
from django.views.generic.edit import UpdateView

class BookUpdateView(UpdateView):
    model         = Book
    fields        = ['title', 'author', 'pages', 'pub_date']
    template_name = 'books/book_form.html'  # same template as Create!
    # success_url defaults to get_absolute_url()

DeleteView — Full Example

DeleteView works differently from the form-based views. On GET, it renders a confirmation page (book_confirm_delete.html) showing the object that's about to be removed. On POST, it deletes the object and redirects to success_url. Unlike Create and Update, you must set success_url explicitly — once the object is deleted, there's no object left to call get_absolute_url() on. Use reverse_lazy() here because this is a class-level attribute evaluated at import time.

books/views.pyPython
from django.views.generic.edit import DeleteView
from django.urls import reverse_lazy

class BookDeleteView(DeleteView):
    model       = Book
    success_url = reverse_lazy('book-list')
    # GET  → renders book_confirm_delete.html with {{ object }}
    # POST → deletes and redirects to success_url
⚠️ reverse_lazy vs reverse Use reverse_lazy() for class-level attributes like success_url because the URL conf isn't loaded yet when the class is defined. Use reverse() inside methods like get_success_url() instead.

Understanding Mixins

One of the biggest advantages of CBVs over FBVs is the ability to compose behaviour using mixins. A mixin is a small, focused class that adds a specific capability — like requiring authentication or checking permissions — without modifying the view's core logic. You "mix in" behaviour by adding the mixin to the view's inheritance list.

Python uses Method Resolution Order (MRO) to decide which class's method runs first when multiple parent classes define the same method. The rule is simple: classes listed first take priority. This is why mixins must always come before the view class — if LoginRequiredMixin comes after CreateView, the dispatch() method from CreateView runs first, bypassing the authentication check entirely.

MixinWhat It DoesKey Attribute
LoginRequiredMixinRedirects unauthenticated users to the login pagelogin_url
PermissionRequiredMixinChecks that the user has specific permissions (returns 403 if not)permission_required
UserPassesTestMixinRuns a custom test function — e.g., only the object's owner can edit ittest_func() method
FormMixinAdds form handling to any view (used internally by CreateView, UpdateView)form_class
ContextMixinProvides get_context_data() for injecting extra template variablesextra_context
🔑 The Golden Rule Always list mixins before the view class: class MyView(LoginRequiredMixin, CreateView). If the mixin comes second, its dispatch() override is skipped and the protection doesn't work.

The Complete views.py

books/views.py (all 5 CBVs)Python
from django.views.generic       import ListView, DetailView
from django.views.generic.edit  import CreateView, UpdateView, DeleteView
from django.urls                 import reverse_lazy
from .models                     import Book

class BookListView(ListView):
    model               = Book
    context_object_name = 'books'
    ordering            = ['-pub_date']

class BookDetailView(DetailView):
    model               = Book
    context_object_name = 'book'

class BookCreateView(CreateView):
    model  = Book
    fields = ['title', 'author', 'pages', 'pub_date']

class BookUpdateView(UpdateView):
    model  = Book
    fields = ['title', 'author', 'pages', 'pub_date']

class BookDeleteView(DeleteView):
    model       = Book
    success_url = reverse_lazy('book-list')

The Templates

Four templates — one for each interaction. CBVs infer the name automatically from your model and app.

Automatic Template Names

CBVAuto TemplateContext Variable
ListViewbooks/book_list.htmlobject_list / book_list
DetailViewbooks/book_detail.htmlobject / book
CreateViewbooks/book_form.htmlform
UpdateViewbooks/book_form.htmlform, object
DeleteViewbooks/book_confirm_delete.htmlobject

book_list.html

books/templates/books/book_list.htmlHTML
<h1>All Books</h1>
<a href="{% url 'book-create' %}">+ New Book</a>

<ul>
{% for book in books %}
  <li>
    <a href="{{ book.get_absolute_url }}">{{ book.title }}</a>
    — {{ book.author }}
    <a href="{% url 'book-update' book.pk %}">Edit</a>
    <a href="{% url 'book-delete' book.pk %}">Delete</a>
  </li>
{% empty %}
  <li>No books yet.</li>
{% endfor %}
</ul>

book_detail.html

books/templates/books/book_detail.htmlHTML
<h1>{{ book.title }}</h1>
<p>Author: {{ book.author }}</p>
<p>Pages: {{ book.pages }}</p>
<p>Published: {{ book.pub_date }}</p>

<a href="{% url 'book-update' book.pk %}">Edit</a>
<a href="{% url 'book-delete' book.pk %}">Delete</a>
<a href="{% url 'book-list' %}">← Back</a>

book_form.html (Create & Update share this!)

books/templates/books/book_form.htmlHTML
<h1>{% if object %}Edit{% else %}New{% endif %} Book</h1>

<form method="post">
  {% csrf_token %}
  {{ form.as_p }}
  <button type="submit">Save</button>
</form>

<a href="{% url 'book-list' %}">Cancel</a>

book_confirm_delete.html

books/templates/books/book_confirm_delete.htmlHTML
<h1>Delete "{{ object.title }}"?</h1>
<p>This action cannot be undone.</p>

<form method="post">
  {% csrf_token %}
  <button type="submit">Yes, Delete</button>
</form>

<a href="{{ object.get_absolute_url }}">Cancel</a>
💡 {% csrf_token %} Always include {% csrf_token %} in every POST form. Django rejects POST requests that don't include it — it's a security feature protecting against Cross-Site Request Forgery.

URL Routing

The final piece — mapping URL patterns to your CBVs using .as_view().

🔑 .as_view() CBVs are classes, but Django's URL dispatcher expects callables. MyView.as_view() converts the class into a view function. Never forget this!

books/urls.py

books/urls.pyPython
from django.urls import path
from .views import (
    BookListView, BookDetailView,
    BookCreateView, BookUpdateView, BookDeleteView
)

urlpatterns = [
    path('',                  BookListView.as_view(),   name='book-list'),
    path('<int:pk>/',         BookDetailView.as_view(), name='book-detail'),
    path('new/',              BookCreateView.as_view(), name='book-create'),
    path('<int:pk>/edit/',   BookUpdateView.as_view(), name='book-update'),
    path('<int:pk>/delete/', BookDeleteView.as_view(), name='book-delete'),
]

Resolved URL Table

NameURLExample
book-list/books/{% url 'book-list' %}
book-detail/books/3/{% url 'book-detail' book.pk %}
book-create/books/new/{% url 'book-create' %}
book-update/books/3/edit/{% url 'book-update' book.pk %}
book-delete/books/3/delete/{% url 'book-delete' book.pk %}

Adding Login Protection

You can protect write operations with LoginRequiredMixin — always put mixins before the CBV in the inheritance chain:

books/views.pyPython
from django.contrib.auth.mixins import LoginRequiredMixin

class BookCreateView(LoginRequiredMixin, CreateView):
    model  = Book
    fields = ['title', 'author', 'pages', 'pub_date']

class BookUpdateView(LoginRequiredMixin, UpdateView):
    model  = Book
    fields = ['title', 'author', 'pages', 'pub_date']

class BookDeleteView(LoginRequiredMixin, DeleteView):
    model       = Book
    success_url = reverse_lazy('book-list')
💡 Why does mixin order matter? Python reads the inheritance list left-to-right. Writing class MyView(CreateView, LoginRequiredMixin) means CreateView.dispatch() runs first — it processes the request normally, never checking authentication. Writing class MyView(LoginRequiredMixin, CreateView) means LoginRequiredMixin.dispatch() runs first — it checks if the user is logged in, and only then calls super().dispatch() which reaches CreateView. The same principle applies to PermissionRequiredMixin, UserPassesTestMixin, and any custom mixin you write.

Knowledge Quiz

Five questions to verify your understanding. Click an answer to reveal the explanation.

1. Which CBV automatically handles both the blank form (GET) and form submission (POST) for creating a new object?
2. Why must you use reverse_lazy() instead of reverse() for class-level attributes like success_url?
3. What is the default template name that CreateView and UpdateView look for when the model is Book?
4. In a template, what tag generates a URL for a named route that takes a pk argument?
5. You want BookCreateView to redirect to the list page after saving, instead of calling get_absolute_url(). What's the cleanest way?