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
ModelFormedits 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 aqueryset. - An inline formset (
inlineformset_factory) edits objects related to a parent instance by aForeignKey. It wrapsmodelformset_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.nameEditing 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 (default1; use0for an "edit only" grid).can_delete— adds aDELETEcheckbox so existing rows can be removed.can_delete_extra— whenFalse, the blankextraforms get no DELETE box (added in Django 3.2).can_order— adds anORDERfield so the user can reorder rows.max_num/min_num— caps on the number of forms; pair withvalidate_max=True/validate_min=Trueto actually enforce them on submit (otherwise they only affect rendering).fields/exclude— which model fields appear (one is required unless you supply a customform).form— a customModelFormsubclass to control widgets, labels, and per-fieldclean().
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_objectslists 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 anycommit=Falsesave.
# 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:
- Per-form validation lives on a custom
ModelForm(clean_<field>()/clean()) and validates one row in isolation. - Formset-level validation lives on a custom
BaseModelFormSet/BaseInlineFormSetclean()and is where you do cross-form checks — for example, uniqueness across the whole set, which a model'suniqueconstraint 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.