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 verbs — get(), 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
| Aspect | FBV (Function-Based) | CBV (Class-Based) |
|---|---|---|
| Structure | A single function with if/else branches | A class with separate methods per HTTP verb |
| Boilerplate | High — you write form handling, method checks, save + redirect logic every time | Low — generic views handle all of this with a few attributes |
| Reusability | Limited — extracting shared logic requires decorators or helper functions | High — use inheritance and mixins to compose behaviour |
| Readability | Excellent for simple views — the entire flow is in one place | Excellent for standard patterns — but can feel "magic" until you understand the method chain |
| Extensibility | Use decorators (@login_required) | Use mixins (LoginRequiredMixin) — stackable and order-sensitive |
| Learning curve | Low — just Python functions | Moderate — requires understanding inheritance, MRO, and which methods to override |
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:
| Operation | HTTP Method | URL Example | CBV |
|---|---|---|---|
| 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
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.
CreateView inherits from BaseCreateView → ModelFormMixin → FormMixin → ContextMixin → View. 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.
The Model We'll Use
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})
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
python -m venv venv
source venv/bin/activate # Windows: venv\Scripts\activate
pip install djangodjango-admin startproject mysite .
python manage.py startapp booksINSTALLED_APPS = [
# ... built-ins ...
'books', # ← add this
]python manage.py makemigrations
python manage.py migrate
python manage.py createsuperuser # optionalfrom django.urls import path, include
urlpatterns = [
path('books/', include('books.urls')),
]Recommended File Structure
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.
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)
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})
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)
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})
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()
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.
object_list (or book_list).pk or slug. Raises 404 automatically if not found.get_absolute_url().book_form.html) by default.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.
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.
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.
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.
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.
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() 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.
| Mixin | What It Does | Key Attribute |
|---|---|---|
LoginRequiredMixin | Redirects unauthenticated users to the login page | login_url |
PermissionRequiredMixin | Checks that the user has specific permissions (returns 403 if not) | permission_required |
UserPassesTestMixin | Runs a custom test function — e.g., only the object's owner can edit it | test_func() method |
FormMixin | Adds form handling to any view (used internally by CreateView, UpdateView) | form_class |
ContextMixin | Provides get_context_data() for injecting extra template variables | extra_context |
class MyView(LoginRequiredMixin, CreateView). If the mixin comes second, its dispatch() override is skipped and the protection doesn't work.
The Complete views.py
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
| CBV | Auto Template | Context Variable |
|---|---|---|
ListView | books/book_list.html | object_list / book_list |
DetailView | books/book_detail.html | object / book |
CreateView | books/book_form.html | form |
UpdateView | books/book_form.html | form, object |
DeleteView | books/book_confirm_delete.html | object |
book_list.html
<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
<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!)
<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
<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 %} 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().
MyView.as_view() converts the class into a view function. Never forget this!
books/urls.py
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
| Name | URL | Example |
|---|---|---|
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:
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')
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.
reverse_lazy() instead of reverse() for class-level attributes like success_url?CreateView and UpdateView look for when the model is Book?BookCreateView to redirect to the list page after saving, instead of calling get_absolute_url(). What's the cleanest way?