Chapter 6 · Views

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.

Figure 6.4 The two requests of a form view. A "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.get_form 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.get_form_kwargs 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.get_context_data 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.form_valid 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.get_form_class 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.get_form_kwargs 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.form_valid calls the form’s save, keeps what it returns as the view’s object, and goes on to FormMixin.form_valid for the redirect. ModelFormMixin.get_success_url 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 "/articles/{slug}/". 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.post, which finds the object and goes through the posting half of the cycle itself, and BaseDeleteView.form_valid 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.get_success_url 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.delete 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.delete finds the object, deletes it and redirects, so a confirmation form guards the "POST" and not the "DELETE".