Video coming soon

The recording for this lesson has not been published yet.

What we cover

  • formset_factory and modelformset_factory
  • inlineformset_factory for post images
  • The management form and why it is required
  • Saving a parent and its children together

Lesson notes

1. A formset is a form of forms

So far one page meant one form and one object. But a post has a gallery, a recipe has ingredients, an invoice has lines. Rendering ten separate <form> tags with ten submit buttons is painful for the user and worse for you. A formset renders several copies of the same form inside one HTML form, validates them together and saves them together.

FactoryBuilds forms fromTypical use
formset_factory A plain Form Three search filters, a list of email addresses to invite
modelformset_factory A ModelForm over a queryset Edit every published post's title on one page
inlineformset_factory A ModelForm for children of one parent Images belonging to a single post

2. What a formset is made of

<form method="post"> - a single POST csrf_token formset.management_form (hidden inputs) TOTAL_FORMS - INITIAL_FORMS - MIN_NUM_FORMS - MAX_NUM_FORMS form-0 id=3, caption, DELETE form-1 id=7, caption, DELETE form-2 (extra) empty = ignored one submit button - formset.is_valid() validates every form above field names are prefixed: images-0-caption

3. The management form is not optional

HTTP sends a flat dictionary of strings. Nothing in it says "there were three forms, two of which already existed". The management form carries exactly that bookkeeping in hidden inputs:

images-TOTAL_FORMS      3   how many forms were rendered
images-INITIAL_FORMS    2   how many of them map to existing rows
images-MIN_NUM_FORMS    0
images-MAX_NUM_FORMS    10

Django uses TOTAL_FORMS to know how many prefixes to look for, and INITIAL_FORMS to tell an update from an insert.

4. modelformset_factory: many objects, one page

# blog/views.py
from django.forms import modelformset_factory
from django.shortcuts import redirect, render

from .models import Post

PostFormSet = modelformset_factory(
    Post,
    fields=["title", "published_date"],
    extra=1,            # how many blank forms to add
    can_delete=True,    # renders a DELETE checkbox per form
    max_num=20,
)


def post_bulk_edit(request):
    queryset = Post.objects.order_by("-created_date")
    formset = PostFormSet(request.POST or None, queryset=queryset)
    if request.method == "POST" and formset.is_valid():
        formset.save()
        return redirect("blog:post_list")
    return render(request, "blog/post_bulk_edit.html", {"formset": formset})

formset.save() does three things in one call: it updates the changed existing rows, inserts the filled-in extra forms and deletes the ones whose DELETE box is ticked. If you need to touch the objects first, use formset.save(commit=False), which gives you formset.new_objects, formset.changed_objects and formset.deleted_objects.

5. inlineformset_factory: a post and its images

This is the case you actually want for the blog. First a child model:

# blog/models.py
class PostImage(models.Model):
    post = models.ForeignKey(Post, on_delete=models.CASCADE, related_name="images")
    image = models.ImageField(upload_to="posts/gallery/")
    caption = models.CharField(max_length=140, blank=True)
    position = models.PositiveSmallIntegerField(default=0)

    class Meta:
        ordering = ["position", "pk"]

    def __str__(self):
        return self.caption or self.image.name
# blog/forms.py
from django.forms import inlineformset_factory

from .models import Post, PostImage

PostImageFormSet = inlineformset_factory(
    Post,                 # parent
    PostImage,            # child
    fields=["image", "caption", "position"],
    extra=2,
    max_num=10,
    can_delete=True,
    widgets={"caption": forms.TextInput(attrs={"class": "form-control"})},
)

The factory knows the foreign key, so it fills post_id for you. You never render or trust that field.

# blog/views.py
from django.db import transaction


def post_edit(request, pk):
    post = get_object_or_404(Post, pk=pk)
    form = PostForm(request.POST or None, request.FILES or None, instance=post)
    formset = PostImageFormSet(
        request.POST or None, request.FILES or None, instance=post
    )

    if request.method == "POST" and form.is_valid() and formset.is_valid():
        with transaction.atomic():
            post = form.save()
            formset.instance = post      # matters when the post is brand new
            formset.save()
        messages.success(request, "Post and gallery saved.")
        return redirect(post.get_absolute_url())

    return render(
        request, "blog/post_edit.html", {"form": form, "formset": formset}
    )

6. Rendering the formset

<form method="post" enctype="multipart/form-data" novalidate>
  {% csrf_token %}

  {{ form.as_div }}

  <h3 class="mt-4">Gallery</h3>
  {{ formset.management_form }}

  {% for error in formset.non_form_errors %}
    <div class="alert alert-danger" role="alert">{{ error }}</div>
  {% endfor %}

  <div id="gallery-forms">
    {% for image_form in formset %}
      <div class="card mb-2 formset-row">
        <div class="card-body">
          {{ image_form.id }}
          {{ image_form.as_div }}
          {% if image_form.instance.pk and image_form.instance.image %}
            <img src="{{ image_form.instance.image.url }}" height="80" alt="">
          {% endif %}
        </div>
      </div>
    {% endfor %}
  </div>

  <button type="submit" class="btn btn-primary">Save everything</button>
</form>

Two things are easy to get wrong here. Render {{ image_form.id }} (or the whole form, which includes it) or Django cannot match a posted row to an existing object. And never put a nested <form> inside the outer one - HTML does not allow it and the inner one is silently dropped.

7. Adding rows in the browser

extra gives you a fixed number of blank rows. To add one on demand, clone the hidden template form that Django provides as formset.empty_form - its indexes are the literal string __prefix__ - and bump TOTAL_FORMS:

<template id="empty-form">
  <div class="card mb-2 formset-row">{{ formset.empty_form.as_div }}</div>
</template>

<button type="button" class="btn btn-outline-secondary" id="add-image">
  Add another image
</button>
const total = document.querySelector("#id_images-TOTAL_FORMS");

document.querySelector("#add-image").addEventListener("click", () => {
  const index = Number(total.value);
  const html = document.querySelector("#empty-form").innerHTML
                       .replaceAll("__prefix__", index);
  document.querySelector("#gallery-forms").insertAdjacentHTML("beforeend", html);
  total.value = index + 1;
});

Deleting is simpler: with can_delete=True each row has a DELETE checkbox, and ticking it removes the object on save. For a row that was never saved, just hide it and decrement TOTAL_FORMS.

8. Validating the set as a whole

A rule that spans forms - "captions must be unique", "at least one image" - belongs in clean() on a BaseInlineFormSet subclass, where an error becomes a non_form_error.

from django.core.exceptions import ValidationError
from django.forms import BaseInlineFormSet


class BasePostImageFormSet(BaseInlineFormSet):
    def clean(self):
        super().clean()
        if any(self.errors):
            return               # per-form errors first, do not pile on

        captions = []
        for form in self.forms:
            if not form.cleaned_data or form.cleaned_data.get("DELETE"):
                continue
            caption = form.cleaned_data.get("caption", "")
            if caption and caption in captions:
                raise ValidationError(f"The caption '{caption}' is used twice.")
            captions.append(caption)

        if not captions:
            raise ValidationError("Add at least one image to the gallery.")


PostImageFormSet = inlineformset_factory(
    Post,
    PostImage,
    formset=BasePostImageFormSet,
    fields=["image", "caption"],
    extra=2,
)

Note the DELETE check and the empty cleaned_data check: an untouched extra form has nothing in it and must be skipped, or your "at least one" rule will reject perfectly good input. If you want Django to enforce a minimum for you, pass min_num=1, validate_min=True to the factory.

9. Exercise

  1. Add the PostImage model with a related_name of images and migrate.
  2. Build PostImageFormSet with inlineformset_factory and render it below PostForm on the edit page.
  3. Delete {{ formset.management_form }}, submit, and read the exception carefully. Put it back.
  4. Upload two images in one submit, then tick a DELETE box and confirm the row disappears.
  5. Add the "at least one image" rule and check that the message shows up in non_form_errors, not next to a field.
  6. Wire the "add another image" button with the empty_form trick and add a third image without reloading the page.

Further reading

After this lesson you will

  • Edit several related objects on a single page
  • Handle adding and deleting rows in one submit