Django Model Formsets & Inline Formsets: A Deep Dive

Blog / Django · December 4, 2020 · Updated June 10, 2026 · 10 min read
Django Model Formsets & Inline Formsets: A Deep Dive

Django formsets let you work with a collection of forms on a single page instead of one form per request. A model formset is a formset bound to a model: each form in the set is a ModelForm, so the whole set can create, edit, and delete many rows of a table in one POST. An inline formset is a specialised model formset for editing the children of a parent object (a one-to-many relationship) on the same page as the parent.

This is a 2026, Django 5.x refresh of our formsets guide. If you are new to forms in Django, start with Django forms basics and then come back here. The two factory functions you will use are modelformset_factory() and inlineformset_factory(), both from django.forms.

Plain form vs model formset vs inline formset

  • A plain ModelForm edits exactly one object. Use it for "create one project" or "edit my profile".
  • A model formset (modelformset_factory) edits many objects of the same model at once — think of an admin-style "edit all open tasks" grid. You usually pass it a queryset.
  • An inline formset (inlineformset_factory) edits objects related to a parent instance by a ForeignKey. It wraps modelformset_factory, automatically restricts the queryset to that parent's children, and sets the foreign key for you on save. Use it for "a project and its tasks on one screen".

Rule of thumb: parent + children on one page → inline formset; a flat list of one model → model formset; a single record → plain form.

A model to work with

We will use a simple parent/child pair — a Project with many Task rows. Note that ForeignKey requires an explicit on_delete (this has been mandatory since Django 2.0).

# models.py
from django.db import models


class Project(models.Model):
    name = models.CharField(max_length=200)
    created_at = models.DateTimeField(auto_now_add=True)

    def __str__(self):
        return self.name


class Task(models.Model):
    project = models.ForeignKey(
        Project, on_delete=models.CASCADE, related_name="tasks"
    )
    title = models.CharField(max_length=200)
    done = models.BooleanField(default=False)
    tags = models.ManyToManyField("Tag", blank=True)

    def __str__(self):
        return self.title


class Tag(models.Model):
    name = models.CharField(max_length=50)

    def __str__(self):
        return self.name

Editing many rows with modelformset_factory

A model formset edits rows of a single model. Prefer listing the editable fields (or exclude) explicitly — passing exclude=() to include every field is a habit worth dropping.

# forms.py
from django.forms import modelformset_factory
from .models import Task

TaskListFormSet = modelformset_factory(
    Task,
    fields=["title", "done"],
    extra=0,            # how many blank forms to add for new rows
    can_delete=True,    # render a DELETE checkbox per row
)

Instantiate it with a queryset to scope which rows it manages. Anything outside that queryset is untouched.

# views.py
from django.shortcuts import render, redirect
from .forms import TaskListFormSet
from .models import Task


def edit_open_tasks(request):
    qs = Task.objects.filter(done=False).order_by("title")
    if request.method == "POST":
        formset = TaskListFormSet(request.POST, queryset=qs)
        if formset.is_valid():
            formset.save()
            return redirect("task-list")
    else:
        formset = TaskListFormSet(queryset=qs)
    return render(request, "tasks/edit.html", {"formset": formset})

Factory options you will actually use

Both factories share most of these keyword arguments:

  • extra — number of blank forms rendered for adding new rows (default 1; use 0 for an "edit only" grid).
  • can_delete — adds a DELETE checkbox so existing rows can be removed.
  • can_delete_extra — when False, the blank extra forms get no DELETE box (added in Django 3.2).
  • can_order — adds an ORDER field so the user can reorder rows.
  • max_num / min_num — caps on the number of forms; pair with validate_max=True / validate_min=True to actually enforce them on submit (otherwise they only affect rendering).
  • fields / exclude — which model fields appear (one is required unless you supply a custom form).
  • form — a custom ModelForm subclass to control widgets, labels, and per-field clean().
TaskListFormSet = modelformset_factory(
    Task,
    fields=["title", "done"],
    extra=2,
    max_num=20,
    validate_max=True,     # enforce max_num when the form is submitted
    can_order=True,        # adds an ORDER field per form
    can_delete=True,
    can_delete_extra=False,  # don't show DELETE on the blank extras
)

The management form — never forget it

Every formset ships hidden bookkeeping inputs that Django uses to reconstruct the set on POST. They live in the management form and have the prefix of the formset (the default model-formset prefix is form):

  • form-TOTAL_FORMS — how many forms were rendered (your JavaScript updates this when adding rows).
  • form-INITIAL_FORMS — how many were bound to existing objects.
  • form-MIN_NUM_FORMS / form-MAX_NUM_FORMS — the min/max hints.

If you render forms by hand you must output {{ formset.management_form }}, or every submit raises ManagementForm data is missing or has been tampered with. {{ formset }} includes it automatically; manual loops do not.

{# tasks/edit.html #}
<form method="post">
  {% csrf_token %}
  {{ formset.management_form }}      {# REQUIRED when looping by hand #}
  {{ formset.non_form_errors }}      {# formset-level (cross-form) errors #}
  {% for form in formset %}
    <div class="row">{{ form.as_p }}</div>
  {% endfor %}
  <button type="submit">Save</button>
</form>

Saving: save(), commit=False, deletes, and M2M

For a plain model formset, formset.save() creates new rows, updates changed ones, and (when can_delete=True) deletes the ones whose DELETE box was ticked. When you need to set extra attributes before writing, use commit=False — but then you are responsible for deletes and for any many-to-many data:

  • formset.save(commit=False) returns the list of new/changed instances without hitting the database.
  • formset.deleted_objects lists the rows marked for deletion — delete them yourself.
  • formset.save_m2m() writes deferred many-to-many relations (e.g. Task.tags); it is required after any commit=False save.
# views.py — commit=False so we can stamp each row
def edit_open_tasks(request):
    qs = Task.objects.filter(done=False)
    if request.method == "POST":
        formset = TaskListFormSet(request.POST, queryset=qs)
        if formset.is_valid():
            instances = formset.save(commit=False)
            for obj in instances:
                obj.updated_by = request.user   # extra attribute
                obj.save()
            for obj in formset.deleted_objects:
                obj.delete()                    # YOU handle deletes
            formset.save_m2m()                  # required after commit=False
            return redirect("task-list")
    else:
        formset = TaskListFormSet(queryset=qs)
    return render(request, "tasks/edit.html", {"formset": formset})

Validation: per-form clean() vs formset clean()

There are two layers of validation:

  1. Per-form validation lives on a custom ModelForm (clean_<field>() / clean()) and validates one row in isolation.
  2. Formset-level validation lives on a custom BaseModelFormSet/BaseInlineFormSet clean() and is where you do cross-form checks — for example, uniqueness across the whole set, which a model's unique constraint cannot express for unsaved rows.

Inside the formset clean(), guard with if any(self.errors): return so you do not crash on rows that already failed, and skip rows flagged for deletion. Errors you raise here surface via formset.non_form_errors().

# forms.py
from django.core.exceptions import ValidationError
from django.forms import BaseInlineFormSet, inlineformset_factory
from .models import Project, Task


class BaseTaskFormSet(BaseInlineFormSet):
    def clean(self):
        super().clean()
        if any(self.errors):
            return  # individual rows failed; skip cross-form checks
        seen = set()
        for form in self.forms:
            # ignore empty extras and rows marked for deletion
            if not form.has_changed():
                continue
            if self.can_delete and self._should_delete_form(form):
                continue
            title = form.cleaned_data.get("title")
            if title in seen:
                raise ValidationError("Task titles must be unique within a project.")
            seen.add(title)


TaskFormSet = inlineformset_factory(
    Project,
    Task,
    formset=BaseTaskFormSet,
    fields=["title", "done"],
    extra=1,
    can_delete=True,
)

Inline formsets: a parent plus its children

inlineformset_factory(Project, Task, ...) builds a formset whose forms are Task rows tied to a Project. You bind it to a parent by passing instance=project; the factory then limits the queryset to that project's tasks and sets each task's project FK automatically on save — so a plain formset.save() is usually all you need.

Tip: pass an explicit prefix (here "tasks") so the management-form input names are predictable (tasks-TOTAL_FORMS, etc.), which keeps the add-row JavaScript simple.

# views.py — function-based create/edit of a project + its tasks
from django.shortcuts import render, redirect, get_object_or_404
from .forms import TaskFormSet
from .models import Project


def edit_project(request, pk=None):
    project = get_object_or_404(Project, pk=pk) if pk else Project()
    if request.method == "POST":
        formset = TaskFormSet(request.POST, instance=project, prefix="tasks")
        if formset.is_valid():
            project.name = request.POST["name"]
            project.save()
            formset.instance = project   # ensure FK target after a fresh save
            formset.save()               # FK on each Task is set for you
            return redirect("project-detail", pk=project.pk)
    else:
        formset = TaskFormSet(instance=project, prefix="tasks")
    return render(request, "projects/form.html",
                  {"project": project, "formset": formset})

Rendering an inline formset (and exposing empty_form)

Render the parent fields, the management form, the existing/extra rows, and a hidden empty_form template. formset.empty_form is a blank form whose field names contain the literal placeholder __prefix__; we will swap that for a real index in JavaScript. Keeping it inside a <template> element means the browser never renders or submits the placeholder fields.

{# projects/form.html #}
<form method="post">
  {% csrf_token %}

  <label>Project name <input name="name" value="{{ project.name }}"></label>

  {{ formset.management_form }}
  {{ formset.non_form_errors }}

  <div id="forms-container">
    {% for form in formset %}
      <div class="task-form">{{ form.as_p }}</div>
    {% endfor %}
  </div>

  {# blank prototype with __prefix__ placeholders, not submitted #}
  <template id="empty-form-template">
    <div class="task-form">{{ formset.empty_form.as_p }}</div>
  </template>

  <button type="button" id="add-task">Add task</button>
  <button type="submit">Save</button>
</form>

Adding rows dynamically with JavaScript

The __prefix__ trick is the canonical way to add rows on the client: clone the empty_form markup, replace __prefix__ with the next index, append it, and bump TOTAL_FORMS so Django knows to process the new row. Here it is in plain vanilla JavaScript (no framework, no TypeScript).

// add-task.js — vanilla JS, progressive enhancement
document.getElementById("add-task").addEventListener("click", addTaskForm);

/**
 * Clone the hidden empty_form prototype, replace the __prefix__ placeholder with
 * the next form index, append it to the container, and increment TOTAL_FORMS so
 * Django validates and saves the newly added row.
 * @returns {void}
 */
function addTaskForm() {
  const total = document.getElementById("id_tasks-TOTAL_FORMS");
  const index = parseInt(total.value, 10);
  const markup = document.getElementById("empty-form-template").innerHTML;
  const container = document.getElementById("forms-container");

  container.insertAdjacentHTML(
    "beforeend",
    markup.replaceAll("__prefix__", String(index))
  );
  total.value = String(index + 1);
}

If you would rather not hand-roll this, a few popular helpers do the same job: django-formset (server-driven widgets and client validation), htmx (fetch a new row fragment from the server and swap it in), and Alpine.js (small reactive sprinkles for show/hide and counters). They are conveniences — the underlying __prefix__ / TOTAL_FORMS contract is identical. For parent → child → grandchild structures, see our walkthrough on nested formsets in Django.

Using formsets with class-based views

Django's generic CBVs do not handle a second formset out of the box, so the established pattern is to add it via get_context_data() and process it in form_valid(). (New to CBVs? See an introduction to Django class-based views.)

# views.py
from django.urls import reverse_lazy
from django.views.generic import CreateView
from .forms import TaskFormSet
from .models import Project


class ProjectCreateView(CreateView):
    model = Project
    fields = ["name"]
    template_name = "projects/form.html"
    success_url = reverse_lazy("project-list")

    def get_context_data(self, **kwargs):
        ctx = super().get_context_data(**kwargs)
        if self.request.method == "POST":
            ctx["formset"] = TaskFormSet(self.request.POST, prefix="tasks")
        else:
            ctx["formset"] = TaskFormSet(prefix="tasks")
        return ctx

    def form_valid(self, form):
        ctx = self.get_context_data()
        formset = ctx["formset"]
        if not formset.is_valid():
            return self.render_to_response(self.get_context_data(form=form))
        self.object = form.save()
        formset.instance = self.object
        formset.save()
        return super().form_valid(form)

Testing formsets

The thing that trips people up in tests is the management form: a POST that omits TOTAL_FORMS/INITIAL_FORMS fails validation before your rows are ever seen. Always include those keys. For more on testing forms and views, see Django unit test cases with forms and views.

# tests.py
from django.test import TestCase
from django.urls import reverse
from .models import Project, Task


class ProjectFormSetTests(TestCase):
    def test_create_project_with_tasks(self):
        data = {
            "name": "Launch",
            # management form — required, or validation fails early
            "tasks-TOTAL_FORMS": "2",
            "tasks-INITIAL_FORMS": "0",
            "tasks-MIN_NUM_FORMS": "0",
            "tasks-MAX_NUM_FORMS": "1000",
            # the actual rows
            "tasks-0-title": "Design",
            "tasks-0-done": "on",
            "tasks-1-title": "Build",
        }
        response = self.client.post(reverse("project-create"), data)

        self.assertEqual(response.status_code, 302)
        self.assertEqual(Project.objects.count(), 1)
        self.assertEqual(Task.objects.count(), 2)

Wrapping up

Model and inline formsets remove a huge amount of boilerplate from "edit many rows" and "parent + children" screens. Keep these in mind: render the management form, list fields explicitly, choose commit=False only when you need extra control (then handle deleted_objects and save_m2m()), do cross-row checks in the formset clean(), and lean on empty_form + __prefix__ for dynamic rows. If you would like a hand designing complex multi-model forms or modernizing an older Django app, our Django development services team can help.

Frequently Asked Questions

What is the difference between a model formset and an inline formset?

A model formset (modelformset_factory) edits many rows of one model and is typically given a queryset. An inline formset (inlineformset_factory) is a model formset tied to a parent object through a ForeignKey: it auto-filters the queryset to that parent's children and sets the FK on save. Use inline formsets for "parent + children on one page".

Why do I get "ManagementForm data is missing or has been tampered with"?

Your POST does not contain the management form fields (<prefix>-TOTAL_FORMS, <prefix>-INITIAL_FORMS, etc.). This happens when you loop over forms by hand but forget {{ formset.management_form }} in the template, or when a test/AJAX payload omits those keys. Render the management form and include those fields on every submit.

When should I use save(commit=False) with a formset?

Use it when you need to set attributes that are not form fields (an owner, a timestamp, a tenant), or when you must control ordering of writes. After a commit=False save you must save the returned instances yourself, delete formset.deleted_objects, and call formset.save_m2m() to persist many-to-many data.

How do I validate uniqueness or relationships across all the forms?

Subclass BaseModelFormSet or BaseInlineFormSet and override clean(). Guard with if any(self.errors): return, skip rows marked for deletion, then compare values across self.forms. Raise ValidationError for problems; those messages appear via formset.non_form_errors().

How do I add formset rows dynamically without a JavaScript framework?

Render {{ formset.empty_form }} inside a hidden <template>. In JavaScript, read TOTAL_FORMS, clone the template markup, replace the literal __prefix__ with the current count, append it, and increment TOTAL_FORMS. Helpers like django-formset, htmx, or Alpine.js automate this but use the same contract.

How many forms can a formset handle, and how do I cap it?

Control the count with extra (blank forms), max_num, and min_num. By default max_num/min_num only affect how many forms render; to enforce them when the user submits, also pass validate_max=True and/or validate_min=True. Django also applies an absolute hard cap of 1000 forms unless you raise max_num.

Share this article