6.8 The form views
FormView, CreateView, UpdateView and DeleteView: the generic views that show a form for a "GET" and act on it for a "POST". They are assembled from ProcessFormView, which holds the cycle, FormMixin, which makes the form, ModelFormMixin, which ties a form to a model’s object, and DeletionMixin.
A form on a web page is a conversation of at least two requests. The first asks for the page and gets an empty form. The second posts what was typed. If that fails validation, the same page goes back with the input still in it and the errors beside the fields, and the conversation goes round again. When it passes, the view does what the form was for and answers with a redirect, which in the words of Django’s tutorial prevents data from being posted twice.
Every view that handles a form goes through that cycle. For FormView, CreateView and UpdateView it is written once, in ProcessFormView, and mixins supply the decisions, as in the display views (The generic views). DeleteView is put together without ProcessFormView and has the posting half written again for itself.
FormView -> FormView > TemplateResponseMixin > BaseFormView > FormMixin > ContextMixin > ProcessFormView > View
CreateView -> CreateView > SingleObjectTemplateResponseMixin > TemplateResponseMixin > BaseCreateView > ModelFormMixin > FormMixin > SingleObjectMixin > ContextMixin > ProcessFormView > View
UpdateView -> UpdateView > SingleObjectTemplateResponseMixin > TemplateResponseMixin > BaseUpdateView > ModelFormMixin > FormMixin > SingleObjectMixin > ContextMixin > ProcessFormView > View
DeleteView -> DeleteView > SingleObjectTemplateResponseMixin > TemplateResponseMixin > BaseDeleteView > DeletionMixin > FormMixin > BaseDetailView > SingleObjectMixin > ContextMixin > View
The cycle: ProcessFormView
ProcessFormView is the whole cycle and nothing else. Its get renders a context. Its post makes the form, asks whether it is valid, and calls form_valid or form_invalid with it. Its put calls post.
Every method that its get and post call belongs to some other class. FormView is the smallest working assembly: FormMixin to make the form, ProcessFormView for the cycle, TemplateResponseMixin to answer.
"GET" makes an unbound form and shows it. A "POST" makes a bound one and takes one of two ways out: back to the page, or on to a redirect.The Gazette takes letters to the editor. Its form wants a name and a letter of at least twenty characters, and its view adds one thing to what FormView does: it keeps the name from a valid form.
# gazette/forms.py
class LetterForm(forms.Form):
name = forms.CharField(max_length=60)
letter = forms.CharField(min_length=20)
# gazette/views.py
class Letters(FormView):
form_class = LetterForm
template_name = "gazette/letters.html"
success_url = reverse_lazy("thanks")
def form_valid(self, form):
RECEIVED.append(form.cleaned_data["name"])
return super().form_valid(form)
This is the request that shows the form:
ProcessFormView.get(request)
FormMixin.get_context_data()
FormMixin.get_form()
FormMixin.get_form_class -> LetterForm
FormMixin.get_form_kwargs
FormMixin.get_initial -> {}
FormMixin.get_prefix -> None
-> a dict with the keys initial, prefix
-> a LetterForm, not bound
ContextMixin.get_context_data(form=...) -> a dict with the keys form, view
-> a dict with the keys form, view
TemplateResponseMixin.render_to_response(context)
TemplateResponseMixin.get_template_names -> ['gazette/letters.html']
get asked for a context, and the form was made on the way, inside get_context_data. A letter that is too short goes round the cycle:
ProcessFormView.post(request)
FormMixin.get_form()
FormMixin.get_form_class -> LetterForm
FormMixin.get_form_kwargs
FormMixin.get_initial -> {}
FormMixin.get_prefix -> None
-> a dict with the keys data, files, initial, prefix
-> a LetterForm, bound
BaseForm.is_valid -> False
FormMixin.form_invalid(form)
...
start_response("200 OK", headers)
the server sends the body: 'Please correct: letter\n'
The answer is a 200, not an error status: an invalid form is an ordinary page as far as HTTP is concerned. A letter the form accepts takes the other way out.
BaseForm.is_valid -> True
Letters.form_valid(form)
FormMixin.form_valid(form)
FormMixin.get_success_url -> '/letters/thanks/'
-> an HttpResponseRedirect, status 302, Location '/letters/thanks/'
-> an HttpResponseRedirect, status 302, Location '/letters/thanks/'
-> an HttpResponseRedirect, status 302, Location '/letters/thanks/'
-> an HttpResponseRedirect, status 302, Location '/letters/thanks/'
-> an HttpResponseRedirect, status 302, Location '/letters/thanks/'
start_response("302 Found", headers), with Location: /letters/thanks/
Making the form: FormMixin
FormMixin calls a form class with keyword arguments. The arguments come from get_form_kwargs, and the class, unless the caller passes one, from get_form_class; both methods can be replaced.
FormMixin is where the two requests of the cycle part. It always supplies "initial", a copy of the initial attribute, and "prefix". For a request whose method is "POST" or "PUT" it adds "data" and "files", the request’s POST and FILES, and a form given data is a bound form. The recordings above show it at that line: two keys for the "GET", four for a "POST".
FormMixin adds the form to the context under "form", making it if the caller did not pass one. That is why a "GET" never calls get_form itself, and why form_invalid, which already has the bound form in hand, passes it along: the page is rendered with the form that failed, and not with a fresh one.
FormMixin does nothing with the form. It redirects to get_success_url, which returns the success_url attribute as a string, or raises ImproperlyConfigured when there is none. Whatever the form was for is the subclass’s to add, in a form_valid of its own that then calls super(), as the Gazette’s does. The attribute is passed through str because, in the words of the comment beside it, it may be lazy. A class attribute is evaluated when the views module is imported, too early for reverse, so a success URL given by a pattern’s name is written with reverse_lazy (Reversing a name).
A model’s form: ModelFormMixin
CreateView and UpdateView edit an object of a model through a ModelForm. ModelFormMixin inherits from both FormMixin and SingleObjectMixin, and adds to the first what the second knows: which model, and which object.
# gazette/views.py
class ArticleCreate(CreateView):
model = Article
fields = ["slug", "headline", "published"]
class ArticleUpdate(UpdateView):
model = Article
fields = ["headline"]
The form class need not be written. ModelFormMixin returns form_class when the view has one. Otherwise it finds a model, from the model attribute, or the class of the object being edited, or the queryset, and calls modelform_factory with it and the view’s fields. A view with neither a form class nor fields raises ImproperlyConfigured, and so does one with both. The factory makes a new class at each call, so a view that relies on it builds a form class for every request.
The object reaches the form as "instance", which ModelFormMixin adds to what FormMixin supplied whenever the view has an attribute object. The two base views differ only in what they put there before the cycle starts. BaseCreateView sets it to None in both handler methods, and the form makes a new object. BaseUpdateView sets it to what get_object finds, by the same lookup a DetailView uses (The generic views).
A valid form is saved. ModelFormMixin calls the form’s save, keeps what it returns as the view’s object, and goes on to FormMixin for the redirect. ModelFormMixin looks in two places for the address. A success_url is filled in from the object’s attributes with str.format, so that it may be written "/. Without one, the object’s own get_absolute_url is called.
BaseCreateView.post(request)
ProcessFormView.post(request)
FormMixin.get_form()
ModelFormMixin.get_form_class
modelform_factory(Article, fields=['slug', 'headline', 'published']) -> the class ArticleForm
-> ArticleForm
ModelFormMixin.get_form_kwargs
FormMixin.get_form_kwargs
FormMixin.get_initial -> {}
FormMixin.get_prefix -> None
-> a dict with the keys data, files, initial, prefix
-> a dict with the keys data, files, initial, instance, prefix
-> an ArticleForm, bound
BaseForm.is_valid
SQL SELECT %s AS "a" FROM "gazette_article" WHERE "gazette_article"."slug" = %s LIMIT 1 with params (1, 'regatta')
-> True
ModelFormMixin.form_valid(form)
BaseModelForm.save
SQL INSERT INTO "gazette_article" ("slug", "headline", "published") VALUES (%s, %s, %s) RETURNING "gazette_article"."id" with params ('regatta', 'Regatta results', '2026-04-05 17:00:00')
-> <Article: Regatta results>
FormMixin.form_valid(form)
ModelFormMixin.get_success_url
Article.get_absolute_url -> '/articles/regatta/'
-> '/articles/regatta/'
-> an HttpResponseRedirect, status 302, Location '/articles/regatta/'
-> an HttpResponseRedirect, status 302, Location '/articles/regatta/'
-> an HttpResponseRedirect, status 302, Location '/articles/regatta/'
-> an HttpResponseRedirect, status 302, Location '/articles/regatta/'
-> an HttpResponseRedirect, status 302, Location '/articles/regatta/'
-> an HttpResponseRedirect, status 302, Location '/articles/regatta/'
start_response("302 Found", headers), with Location: /articles/regatta/
There are two queries, one for validation and one for saving. Validation checked that no other article has the slug, and save inserted the row. An UpdateView begins by finding its object, and the rest of its cycle runs the same methods with that object as the form’s instance. These are two of the places where its recording differs, the beginning and the query save sends. The others come from the Gazette’s own fields: its update view has only the headline, so the factory is given one field, and validation has no slug to check and runs no query.
BaseUpdateView.post(request, slug='regatta')
SingleObjectMixin.get_object()
SingleObjectMixin.get_queryset -> a QuerySet of Article, not yet fetched
SingleObjectMixin.get_slug_field -> 'slug'
SQL SELECT "gazette_article"."id", "gazette_article"."slug", "gazette_article"."headline", "gazette_article"."published" FROM "gazette_article" WHERE "gazette_article"."slug" = %s LIMIT 21 with params ('regatta',)
-> <Article: Regatta results>
ProcessFormView.post(request, slug='regatta')
...
BaseModelForm.save
SQL UPDATE "gazette_article" SET "slug" = %s, "headline" = %s, "published" = %s WHERE "gazette_article"."id" = %s with params ('regatta', 'Regatta results, corrected', '2026-04-05 17:00:00', 9)
-> <Article: Regatta results, corrected>
Both views get their template name from SingleObjectTemplateResponseMixin with a suffix of "_form", so one template serves for making an article and for changing it.
DeleteView
Deleting has the same shape seen from the browser: a page that asks, and a post that acts. BaseDeleteView builds it from three parents, DeletionMixin, FormMixin and BaseDetailView, without ProcessFormView, and sets form_class to Form, a form with no fields, which is valid whenever it is bound.
# gazette/views.py
class ArticleDelete(DeleteView):
model = Article
success_url = reverse_lazy("articles")
DeleteView: which class supplies each method
get -> BaseDetailView
post -> BaseDeleteView
delete -> DeletionMixin
get_object -> SingleObjectMixin
get_form -> FormMixin
get_form_class -> FormMixin
form_valid -> BaseDeleteView
form_invalid -> FormMixin
get_success_url -> DeletionMixin
get_context_data -> FormMixin
get_template_names -> SingleObjectTemplateResponseMixin
A "GET" is answered by the detail view’s get: the object is found and a page rendered, from a template with the suffix "_confirm_delete". A "POST" is answered by BaseDeleteView, which finds the object and goes through the posting half of the cycle itself, and BaseDeleteView deletes it.
BaseDeleteView.post(request, slug='lost-cat')
SingleObjectMixin.get_object()
SingleObjectMixin.get_queryset -> a QuerySet of Article, not yet fetched
SingleObjectMixin.get_slug_field -> 'slug'
SQL SELECT "gazette_article"."id", "gazette_article"."slug", "gazette_article"."headline", "gazette_article"."published" FROM "gazette_article" WHERE "gazette_article"."slug" = %s LIMIT 21 with params ('lost-cat',)
-> <Article: Lost: a grey cat, Quay Street>
FormMixin.get_form()
FormMixin.get_form_class -> Form
FormMixin.get_form_kwargs
FormMixin.get_initial -> {}
FormMixin.get_prefix -> None
-> a dict with the keys data, files, initial, prefix
-> a Form, bound
BaseForm.is_valid -> True
BaseDeleteView.form_valid(form)
DeletionMixin.get_success_url -> '/articles/'
Model.delete
SQL DELETE FROM "gazette_article" WHERE "gazette_article"."id" IN (%s) with params (1,)
-> an HttpResponseRedirect, status 302, Location '/articles/'
-> an HttpResponseRedirect, status 302, Location '/articles/'
-> an HttpResponseRedirect, status 302, Location '/articles/'
-> an HttpResponseRedirect, status 302, Location '/articles/'
start_response("302 Found", headers), with Location: /articles/
form_valid gets the success URL first and deletes second. DeletionMixin fills the success_url in from the object’s attributes, as the model form’s does, and it reads them while the object is still in the database. There is no falling back to get_absolute_url here: a view without a success_url raises ImproperlyConfigured.
DeletionMixin is still there, and View.dispatch still routes to it, because "delete" is one of the names in http_method_names. A request with the method "DELETE" goes to it directly:
View.dispatch(request, slug='found-cat')
DeletionMixin.delete(request, slug='found-cat')
SingleObjectMixin.get_object()
SingleObjectMixin.get_queryset -> a QuerySet of Article, not yet fetched
SingleObjectMixin.get_slug_field -> 'slug'
SQL SELECT "gazette_article"."id", "gazette_article"."slug", "gazette_article"."headline", "gazette_article"."published" FROM "gazette_article" WHERE "gazette_article"."slug" = %s LIMIT 21 with params ('found-cat',)
-> <Article: Found: a grey cat>
DeletionMixin.get_success_url -> '/articles/'
Model.delete
SQL DELETE FROM "gazette_article" WHERE "gazette_article"."id" IN (%s) with params (2,)
-> an HttpResponseRedirect, status 302, Location '/articles/'
-> an HttpResponseRedirect, status 302, Location '/articles/'
-> an HttpResponseRedirect, status 302, Location '/articles/'
start_response("302 Found", headers), with Location: /articles/
No form was made. DeletionMixin finds the object, deletes it and redirects, so a confirmation form guards the "POST" and not the "DELETE".