Model Forms & Formsets
|
This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production. The reference material consulted while preparing these pages is listed in the section bibliography. |
This page is written against Django 6.1 (6.1.1 on PyPI; 6.1.2 is scheduled for 2026-10-06), Python 3.12-3.14 and PostgreSQL 15+,
checked on 2026-10-03; where Django 5.2 LTS behaves differently the text says so. Nothing on this page needs PostgreSQL or Redis: every
listing was run in a scratch project on Python 3.13.16 and Django 6.1.1 with SQLite. manage.py check is clean, the 32 tests excerpted in
Running the examples pass (manage.py test catalog), and a curl session against runserver returned the responses shown there.
A ModelForm is a form whose fields are generated from a model: you name the model and the fields, and Django builds the form fields, validates the posted data against both the form rules and the model rules, and creates or updates the model instance. A formset is a collection of forms of the same class handled as one unit — one POST that edits, adds and deletes several rows. A model formset is a formset of ModelForms over a QuerySet, and an inline formset is a model formset restricted to the children of one parent object (an author and their books). Together they are how a Django project edits related rows on one page without writing the bookkeeping by hand.
This page assumes the plain-Form basics (bound and unbound forms, fields, widgets, the GET/POST/redirect pattern, {% csrf_token %}) from
Forms; the validation cycle in detail is on Form Validation & Validators,
rendering and widgets on Form Rendering & Widgets, and the generic editing views that wrap a ModelForm in
Generic Views. Models and fields are on Models & Fields, relationships on
Relationships, and constraints on Constraints, Indexes & Composite Keys.
The Bookshelf catalog app used on this page
Every example belongs to the small catalog app of the running Bookshelf scenario: authors, books, genres and reader reviews. Four details of the
models matter for the forms below:
-
Book.added_byis a field the server sets (the logged-in user); no form should let the browser choose it. -
Book.genresis a many-to-many field, which is whysave(commit=False)needs a second step (seesave(commit=False): set what the server knows, then save). -
Reviewhas aUniqueConstrainton(book, reviewer), which is what makes the "which fields are validated" rules of ModelForms visible (see How a ModelForm validates: form rules first, then model rules). -
Author→BookandBook→Revieware the two foreign keys the inline formsets are built on.
from django.conf import settings
from django.core.validators import MaxValueValidator, MinValueValidator
from django.db import models
from django.urls import reverse
class Genre(models.Model):
name = models.CharField(max_length=60, unique=True)
class Meta:
ordering = ["name"]
def __str__(self):
return self.name
class Author(models.Model):
name = models.CharField(max_length=200)
slug = models.SlugField(unique=True)
class Meta:
ordering = ["name"]
def __str__(self):
return self.name
class Book(models.Model):
title = models.CharField(max_length=200)
slug = models.SlugField(unique=True)
isbn = models.CharField(max_length=13, unique=True)
author = models.ForeignKey(Author, on_delete=models.PROTECT, related_name="books")
genres = models.ManyToManyField(Genre, blank=True, related_name="books")
price = models.DecimalField(max_digits=6, decimal_places=2)
published = models.DateField()
# Set by the server, never by the form:
added_by = models.ForeignKey(
settings.AUTH_USER_MODEL,
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name="+",
)
class Meta:
ordering = ["title"]
def __str__(self):
return self.title
def get_absolute_url(self):
return reverse("catalog:book-detail", kwargs={"slug": self.slug})
class Review(models.Model):
book = models.ForeignKey(Book, on_delete=models.CASCADE, related_name="reviews")
reviewer = models.ForeignKey(
settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="reviews"
)
rating = models.PositiveSmallIntegerField(
validators=[MinValueValidator(1), MaxValueValidator(5)]
)
body = models.TextField(blank=True)
class Meta:
ordering = ["id"]
constraints = [
models.UniqueConstraint(
fields=["book", "reviewer"], name="one_review_per_reader_and_book"
)
]
def __str__(self):
return f"{self.reviewer} on {self.book}: {self.rating}/5"
The project keeps Django’s defaults for everything else (SQLite, the sessions, messages and CSRF middleware, LOGIN_URL = "/accounts/login/") and includes
the app’s URLs under catalog/. The complete URLconf is shown later in ModelForms in views.
Source: Models, Constraints reference
Where ModelForms and formsets sit in the request/response cycle
Forms are not middleware and not a layer of their own: they are plain Python objects that a view creates, feeds with request.POST
(and request.FILES), asks is_valid(), and then either saves or re-renders. Everything on this page therefore happens inside the view call
shown in The Request/Response Cycle, after URL routing and the middleware chain (which has already done
the CSRF check for the POST) and before the template renders the response.
(CSRF checked by CsrfViewMiddleware)"] --> view["view creates the form
BookForm(request.POST, instance=book)"] view --> valid{"form.is_valid()"} valid --> fields["1 each field: to_python, validate, run_validators
then clean_fieldname()"] fields --> clean["2 form.clean()
cross-field rules, sets the unique/constraint flags"] clean --> post["3 _post_clean()
construct_instance, instance.full_clean(),
validate_unique(), validate_constraints()"] post --> ok{"errors?"} ok -->|"yes"| redisplay["re-render the bound form
status 200 with the errors"] ok -->|"no"| save["form.save(commit=...)
INSERT or UPDATE, then many-to-many"] save --> redirect["redirect (POST/redirect/GET)
302 to the success URL"]
A formset adds one level around that: it validates the management form first, builds one form per row, runs the cycle above for every form, then applies the formset-wide checks and finally saves every form that changed.
prefix-0-title, prefix-1-title ..."] --> mgmt{"management form
present and valid?"} mgmt -->|"no"| tamper["non-form error:
ManagementForm data is missing or has been tampered with"] mgmt -->|"yes"| build["build one form per row
initial rows bound to instances from the queryset"] build --> each["each form: field cleaning, clean(), model validation
empty extra forms are skipped"] each --> limits["validate_max / validate_min
(deleted forms do not count)"] limits --> fclean["formset.clean()
duplicates across forms, your cross-form rules"] fclean --> saved{"is_valid()?"} saved -->|"yes"| save["formset.save():
update changed, delete marked, insert new"] saved -->|"no"| show["re-render forms and non_form_errors"]
Sync and async. The whole machinery is synchronous Python. A form never awaits anything, but several of its steps run ORM queries: a
ModelChoiceField evaluates its queryset when it validates a submitted value and when it renders <option> elements, validate_unique()
and validate_constraints() query the database, and a model formset reads its queryset. Under ASGI you therefore call these from synchronous code (a
sync view, which Django runs in a thread pool) or wrap them with sync_to_async(); calling is_valid() or rendering such a field directly in an
async def view raises SynchronousOnlyOperation. The details, and a working async view, are in Sync and async behaviour.
Source: Form and field validation, Formsets
ModelForm: a form generated from a model
The minimal ModelForm
A ModelForm is a subclass of django.forms.ModelForm with an inner Meta class that names the model and the fields:
from django import forms
from .models import Book
class QuickBookForm(forms.ModelForm):
class Meta:
model = Book
fields = ["title", "isbn", "price"]
Django’s ModelFormMetaclass reads Meta when the class is created and builds the form fields for you. Because that happens at import time,
mistakes in Meta surface when the module is imported, not when a page is requested:
fields or exclude)ImproperlyConfigured - Creating a ModelForm without either the 'fields' attribute or the 'exclude' attribute is prohibited; form Careless needs updating.
This error exists since Django 1.8 because of the security problem described next.
Source: Creating forms from models
Always list the fields explicitly (never __all__, avoid exclude)
Meta.fields accepts a list of names or the string "__all__"; Meta.exclude is the opposite list. Both work, and both are traps:
-
fields = "__all__"andexclude = […]are deny-lists in disguise. The day someone addsis_staff,price_override,added_byorpublishedto the model, the form silently starts accepting it from the browser — a classic mass-assignment vulnerability. An attacker does not need the field to be rendered; any extra POST key with the right name is enough. -
An explicit
fieldslist is an allow-list: a new model field never reaches the form until a developer decides it should. It also fixes the field order and documents the form.
In the catalog app, Book.added_by is never listed, so a forged added_by key is ignored:
def test_posted_added_by_is_ignored(self):
form = BookForm(book_data(author=self.author.pk, added_by=self.user.pk))
self.assertTrue(form.is_valid(), form.errors)
self.assertIsNone(form.save().added_by)
Fields that the model marks editable=False are never part of a ModelForm either, and AutoField/BigAutoField primary keys are not represented.
How model fields become form fields
For every name in fields, Django asks the model field for its default form field (Field.formfield()). The mapping covers the built-in fields:
| Model field | Form field |
|---|---|
|
The matching form field ( |
|
|
|
|
|
The matching form field |
|
|
A field with |
|
|
|
|
|
The conversion also carries the model field’s options over: blank=True becomes required=False, verbose_name becomes the label,
help_text is copied and default becomes the initial value; the model’s validators run during model validation (see How a ModelForm validates: form rules first, then model rules). You can check the result in a shell:
BookForm.base_fields (class shown below){'title': 'CharField', 'slug': 'SlugField', 'isbn': 'CharField', 'author': 'ModelChoiceField', 'genres': 'ModelMultipleChoiceField', 'price': 'DecimalField', 'published': 'DateField'}
Source: Field types
Every Meta option
Meta attribute |
What it does |
|---|---|
|
The model class. Required; without it Django raises |
|
Allow-list of model field names to include, or |
|
Deny-list of names to leave out. Usable instead of |
|
|
|
|
|
|
|
|
|
List of names (or |
|
A callable |
Meta is inherited: a subclass that declares its own inner class should inherit from the parent’s (class Meta(BookForm.Meta): …), otherwise the options are lost and
the "without fields`" error above appears. The next listing uses `labels, help_texts, error_messages and widgets together.
A complete ModelForm
Here is the BookForm that the rest of the page uses. It lists its fields, adjusts the display through Meta, and adds a clean_<field>() method for a rule that belongs to
the form (the Django-level rules of unique=True come from the model for free):
class BookForm(forms.ModelForm):
class Meta:
model = Book
# Explicit list: a new model field never reaches the browser by accident.
fields = ["title", "slug", "isbn", "author", "genres", "price", "published"]
labels = {"isbn": "ISBN"}
help_texts = {"isbn": "Ten or thirteen digits, without hyphens."}
error_messages = {
"slug": {"unique": "Another book already uses this slug."},
}
widgets = {
"published": forms.DateInput(attrs={"type": "date"}, format="%Y-%m-%d"),
"genres": forms.CheckboxSelectMultiple,
}
def clean_isbn(self):
isbn = self.cleaned_data["isbn"]
if not isbn.isdigit() or len(isbn) not in (10, 13):
raise forms.ValidationError("An ISBN has 10 or 13 digits.")
return isbn
The test excerpt shows both the custom clean_isbn() rule and the overridden unique message at work:
def test_clean_isbn_and_custom_unique_message(self):
form = BookForm(book_data(author=self.author.pk, isbn="12-34"))
self.assertFalse(form.is_valid())
self.assertEqual(form.errors["isbn"], ["An ISBN has 10 or 13 digits."])
BookForm(book_data(author=self.author.pk)).save()
again = BookForm(book_data(author=self.author.pk, isbn="9780441172720"))
self.assertFalse(again.is_valid())
self.assertEqual(again.errors["slug"], ["Another book already uses this slug."])
Declaring a field on the form class replaces the generated one entirely and bypasses the model-derived options: title = forms.CharField(max_length=50) is used as written,
whatever Meta.widgets or Meta.labels say about title. Reserve that for fields that genuinely differ from the model, and keep the field in Meta.fields so it is saved.
Source: Creating forms from models
modelform_factory(): a ModelForm without a class statement
modelform_factory(model, form=ModelForm, fields=None, exclude=None, formfield_callback=None, widgets=None, localized_fields=None, labels=None, help_texts=None, error_messages=None, field_classes=None)
builds a ModelForm class on the fly. It is what generic editing views call when you give them model and fields instead of form_class, and it is
handy for one-off forms:
from django.forms import modelform_factory
from .models import Book
PriceForm = modelform_factory(Book, fields=["title", "price"])
# PriceForm.__name__ == "BookForm"; list(PriceForm.base_fields) == ["title", "price"]
Pass form=BookForm to start from your own class and override only some options. Since Django 4.2 the factory respects Meta.formfield_callback of the form it starts from.
The fields argument has the same security caveat as Meta.fields: pass an explicit list.
Source: modelform_factory
Instances: create, update, and initial
A ModelForm always has form.instance, the model object it will save:
-
BookForm(request.POST)— a form for a new object:form.instanceis a freshBook()andsave()will INSERT. -
BookForm(request.POST, instance=book)— editsbookin place;save()will UPDATE the same row. The form fields start with the instance’s values (throughmodel_to_dict()). -
BookForm(initial={"author": author})— pre-fills fields.initialwins over the instance’s values for the names it contains. -
BookForm()— unbound: renders empty (or with initial values) and is never valid.
Note that validation modifies the instance: during is_valid(), construct_instance() copies cleaned_data onto form.instance and calls the model’s validation. When an
update form is invalid, the in-memory book object is left in the half-edited state (it is not saved); do not reuse it for something that must show the stored values.
If you need the original, call refresh_from_db() or load it again.
def test_instance_updates_in_place(self):
book = Book.objects.create(
title="Old", slug="old", isbn="1234567890", author=self.author,
price=Decimal("1.00"), published=datetime.date(2000, 1, 1),
)
form = BookForm(book_data(author=self.author.pk, title="New", slug="old", isbn="1234567890"), instance=book)
self.assertTrue(form.is_valid(), form.errors)
form.save()
book.refresh_from_db()
self.assertEqual(book.title, "New")
self.assertEqual(Book.objects.count(), 1)
form.has_changed() and form.changed_data tell you which fields differ from initial (or from the instance), which is how a formset decides that an extra form
was filled in and must be saved.
Source: The save() method
How a ModelForm validates: form rules first, then model rules
is_valid() runs the plain form cycle (field cleaning, clean_<field>(), clean(); see
Form Validation & Validators), and then _post_clean() — the ModelForm-specific step — does this, in order:
-
construct_instance()copiescleaned_dataontoform.instancefor every field infields. -
instance.full_clean(exclude=…, validate_unique=False, validate_constraints=False)runs the model’s field validators andModel.clean(). -
If
BaseModelForm.clean()ran (see the pitfall below),validate_unique()checks everyunique=Trueandunique_togetherrule with a query, andvalidate_constraints()checks the model’sMeta.constraints(the latter since Django 4.1).
Errors from all three steps are attached to the form like any other error, so a duplicate slug becomes a field error and not an IntegrityError 500. What this means in practice:
-
The model’s validation is the single source of truth. Put reusable rules in model validators,
Model.clean()and constraints, and every ModelForm (and the admin) enforces them. -
Fields missing from the form are skipped. The model rules are only run for fields that are in the form (and did not already fail). A
UniqueConstraint(fields=["book", "reviewer"])is skipped whenrevieweris not a form field, because validation excludes the constraint if any of its fields is excluded. The test below shows the consequence: the form is "valid" and the database raisesIntegrityErroron save. -
Overriding
clean()without callingsuper().clean()stopsvalidate_unique()andvalidate_constraints()from running (the flags are set inBaseModelForm.clean()).
def test_constraint_with_an_excluded_field_is_not_validated(self):
from django.db import IntegrityError, transaction
class Naive(forms.ModelForm):
class Meta:
model = Review
fields = ["book", "rating", "body"]
Review.objects.create(book=self.book, reviewer=self.ann, rating=3)
form = Naive({"book": self.book.pk, "rating": 5})
self.assertTrue(form.is_valid()) # validate_constraints() skipped: reviewer is excluded
review = form.save(commit=False)
review.reviewer = self.ann
with self.assertRaises(IntegrityError), transaction.atomic():
review.save()
The fix used on this page is described in Passing request.user to the form (and keeping a server-controlled field valid): keep the server-controlled field in the form but disabled, so it is validated and cannot be forged.
Restricting choices: limit_choices_to and the field’s queryset
The ModelChoiceField generated for a ForeignKey lists the whole target table. To limit it, set limit_choices_to on the model field (a dict, a Q object or a callable; it applies in
forms and the admin) or replace the queryset in the form’s __init__, which is what ReviewForm does in Passing request.user to the form (and keeping a server-controlled field valid). A queryset restriction is also a validation
rule: a posted primary key outside the queryset produces the "Select a valid choice" error, so a user cannot pick a book the form did not offer.
Source: ModelChoiceField, limit_choices_to
Saving: save(), commit=False and save_m2m()
form.save(commit=True) raises ValueError if the form has errors (reading form.errors validates a bound form that you have not validated yet), otherwise writes the instance with instance.save(), then writes the many-to-many data, and returns the instance.
The ValueError matters: it is how Django tells you that a view saved a form without handling the invalid case; that is a programming error, not a validation error:
False {'isbn': ['An ISBN has 10 or 13 digits.'], 'slug': ['Another book already uses this slug.']}
ValueError - The Book could not be created because the data didn't validate.
save(commit=False): set what the server knows, then save
Some values must never come from the browser: the owner, the current site, a status, a computed field. The pattern is to validate the posted data, get the unsaved instance, set the server-controlled fields, save it yourself, and then save the many-to-many data:
book = form.save(commit=False) # no INSERT/UPDATE, no many-to-many yet
book.added_by = request.user # server-controlled value
book.save()
form.save_m2m() # saves Book.genres from cleaned_data
save_m2m() exists only on a form that was saved with commit=False and only matters if the form has a many-to-many field (or a generic relation). It is the most
frequently forgotten line: skip it and the book is created but its genres are silently lost, as the test shows.
def test_commit_false_defers_instance_and_m2m(self):
form = BookForm(book_data(author=self.author.pk, genres=[self.sf.pk]))
self.assertTrue(form.is_valid(), form.errors)
book = form.save(commit=False)
self.assertIsNone(book.pk)
self.assertTrue(hasattr(form, "save_m2m"))
self.assertFalse(Book.objects.exists())
book.added_by = self.user
book.save()
self.assertEqual(book.genres.count(), 0) # nothing saved for M2M yet
form.save_m2m()
self.assertEqual(list(book.genres.all()), [self.sf])
Rules of thumb for commit=False:
-
Call it only after
is_valid()(the instance holds the validated values). -
The instance has no primary key yet (
book.pk is Nonefor a new object), so you cannot attach related rows to it until you have saved it. -
Wrap multi-step saves in
transaction.atomic()when a half-saved result would be wrong (see Transactions, Raw SQL & Multiple Databases). -
If you set a field after
commit=False, you bypass model validation for it, becausefull_clean()already ran. Validate the values you set yourself, or use the disabled-field technique below. -
save()carriesalters_data = True, so templates cannot call it by accident.
Source: The save() method
ModelForms in views
A function-based view
The standard shape is the same for every form: build it bound (POST) or unbound (GET), validate, save and redirect so that a browser reload does not resubmit.
@login_required
def book_update(request, slug):
"""Function-based ModelForm: bound or unbound, POST/redirect/GET."""
book = get_object_or_404(Book, slug=slug, added_by=request.user)
if request.method == "POST":
form = BookForm(request.POST, instance=book)
if form.is_valid():
form.save()
return redirect(book)
else:
form = BookForm(instance=book)
return render(request, "catalog/book_form.html", {"form": form})
get_object_or_404(Book, slug=slug, added_by=request.user) scopes the lookup to the user’s own books, so another user’s slug is a 404, not a form; the test
test_update_only_own_books asserts that.
A class-based view and get_form_kwargs()
CreateView and UpdateView (see Generic Views) build the ModelForm for you and call form_valid() after validation. Override
form_valid() for the commit=False pattern, and get_form_kwargs() to give the form something extra — typically the logged-in user:
class BookCreateView(LoginRequiredMixin, CreateView):
"""ModelForm in a class-based view; stamps the owner with commit=False."""
form_class = BookForm
template_name = "catalog/book_form.html"
def form_valid(self, form):
book = form.save(commit=False) # no INSERT yet, no M2M yet
book.added_by = self.request.user
book.save()
form.save_m2m() # the genres checkboxes
messages.success(self.request, f"Added {book}.")
return redirect(book)
class ReviewCreateView(LoginRequiredMixin, CreateView):
"""get_form_kwargs() hands request.user to the form."""
form_class = ReviewForm
template_name = "catalog/review_form.html"
success_url = "/catalog/"
def get_form_kwargs(self):
kwargs = super().get_form_kwargs()
kwargs["user"] = self.request.user
return kwargs
get_form_kwargs() is the hook of FormMixin that returns the dictionary passed to the form’s constructor: initial, prefix, and for POST and PUT data and files; ModelFormMixin adds
instance. Extend it with super() first and add your keys, and the form’s __init__ must accept them. It is called once per request when the view builds the form.
LoginRequiredMixin must stand on the left of the bases so that anonymous users are redirected before any form code runs.
def test_create_view_stamps_owner_and_saves_m2m(self):
self.client.force_login(self.ann)
response = self.client.post(
reverse("catalog:book-create"),
book_data(author=self.author.pk, genres=[self.sf.pk]),
)
book = Book.objects.get()
self.assertRedirects(response, book.get_absolute_url())
self.assertEqual(book.added_by, self.ann)
self.assertEqual(list(book.genres.all()), [self.sf])
def test_review_create_view_gets_user_from_get_form_kwargs(self):
book = Book.objects.create(
title="B", slug="b", isbn="1234567890", author=self.author,
price=Decimal("1.00"), published=datetime.date(2000, 1, 1),
)
self.client.force_login(self.ann)
self.client.post(reverse("catalog:review-create"), {"book": book.pk, "rating": 5, "body": "great"})
self.assertEqual(Review.objects.get().reviewer, self.ann)
# The book is now missing from ann's choices, so a second attempt is refused.
response = self.client.post(reverse("catalog:review-create"), {"book": book.pk, "rating": 4})
self.assertEqual(response.status_code, 200)
self.assertIn("book", response.context["form"].errors)
self.assertEqual(Review.objects.count(), 1)
Passing request.user to the form (and keeping a server-controlled field valid)
Giving the form the user lets it do three jobs: restrict a choice field to what this user may pick, validate against the user’s own data, and own a field the browser must not set.
ReviewForm below does all three. The reviewer is declared as a disabled ModelChoiceField: Django ignores whatever is posted for a disabled field and uses its initial value
instead, so the reviewer cannot be forged, while the field stays in Meta.fields and the model’s UniqueConstraint(book, reviewer) is therefore validated and reported as a normal form error
(the alternative commit=False approach skips that check, see How a ModelForm validates: form rules first, then model rules).
class ReviewForm(forms.ModelForm):
"""Takes the logged-in user as a keyword argument and owns the reviewer field."""
class Meta:
model = Review
fields = ["book", "reviewer", "rating", "body"]
def __init__(self, *args, user, **kwargs):
super().__init__(*args, **kwargs)
self.user = user
# A disabled field ignores whatever the browser posts and uses `initial`,
# so the reviewer cannot be forged, yet it is still a form field and the
# model's UniqueConstraint(book, reviewer) is validated.
self.fields["reviewer"] = forms.ModelChoiceField(
queryset=get_user_model()._default_manager.filter(pk=user.pk),
initial=user.pk,
disabled=True,
widget=forms.HiddenInput,
)
if "book" in self.fields: # absent inside the inline formset
# Offer only books this user has not reviewed yet.
reviewed = Review.objects.filter(reviewer=user).exclude(pk=self.instance.pk)
self.fields["book"].queryset = Book.objects.exclude(
pk__in=reviewed.values("book_id")
)
Two details deserve a note. The user passed in by request.user is a SimpleLazyObject, so the form uses get_user_model() rather than type(user) to reach the manager. And the
if "book" in self.fields guard is there because the same form is reused inside an inline formset, where the foreign key to the parent is not a normal field (see Inline formsets: a parent and its children on one page).
def test_posted_reviewer_is_ignored(self):
form = ReviewForm(
{"book": self.book.pk, "reviewer": self.bob.pk, "rating": 4}, user=self.ann
)
self.assertTrue(form.is_valid(), form.errors)
self.assertEqual(form.save().reviewer, self.ann)
def test_unique_constraint_is_checked_for_a_disabled_field(self):
Review.objects.create(book=self.book, reviewer=self.ann, rating=3)
form = ReviewForm({"book": self.book.pk, "rating": 5}, user=self.ann)
self.assertEqual(list(form.fields["book"].queryset), []) # already reviewed
self.assertFalse(form.is_valid())
self.assertIn("book", form.errors)
formset = ReviewFormSet(
formset_data("reviews", [{"rating": "5"}]), instance=self.book, user=self.ann
)
self.assertFalse(formset.is_valid())
self.assertIn("Review with this Book and Reviewer", str(formset.errors))
self.assertEqual(Review.objects.count(), 1)
The template
A ModelForm renders exactly like a plain form (see Form Rendering & Widgets). Two parts are mandatory for a state-changing form: the POST method and the CSRF token.
<h1>Book</h1>
<form method="post" novalidate>
{% csrf_token %}
{{ form }}
<button type="submit">Save</button>
</form>
Since Django 5.0 {{ form }} renders with the div.html template; before that the default was a table. If the form has a file field, the <form> tag needs enctype="multipart/form-data" and the view must pass request.FILES to the form
(see File Uploads & Storage).
Formsets: many forms, one POST
A formset is created by a factory function that returns a formset class; you instantiate the class like a form (bound with request.POST, unbound otherwise) and iterate over it in the template.
| Factory | Builds |
|---|---|
|
A formset of plain |
|
A formset of ModelForms over a QuerySet ( |
|
A model formset limited to the rows that point at one parent instance ( |
|
The same for a generic relation ( |
The management form
A formset needs to know how many forms were submitted, how many of them are rows that already exist, and the limits. That information travels in four hidden inputs called the
management form, named after the formset’s prefix. You must render it ({{ formset.management_form }}) inside the <form> tag, or the POST cannot be interpreted.
<input type="hidden" name="books-TOTAL_FORMS" value="3" id="id_books-TOTAL_FORMS">
<input type="hidden" name="books-INITIAL_FORMS" value="1" id="id_books-INITIAL_FORMS">
<input type="hidden" name="books-MIN_NUM_FORMS" value="0" id="id_books-MIN_NUM_FORMS">
<input type="hidden" name="books-MAX_NUM_FORMS" value="5" id="id_books-MAX_NUM_FORMS">
TOTAL_FORMS is how many forms the POST contains (the server builds exactly that many, up to absolute_max), INITIAL_FORMS is how many of them are rows that already existed; the others are extra forms.
MIN_NUM_FORMS and MAX_NUM_FORMS are informational for JavaScript; the server never trusts them and uses its own min_num/max_num. If the management form is missing or damaged, Django
reports a form-wide validation error rather than raising an exception (since 3.2; earlier versions raised ValidationError as a 500):
def test_missing_management_form_is_a_validation_error(self):
formset = AuthorBooksFormSet({}, instance=self.author)
self.assertFalse(formset.is_valid())
self.assertIn("ManagementForm data is missing", formset.non_form_errors()[0])
The forms themselves use the prefix <prefix>-<index>-<field>, for example books-0-title, which is how one POST carries many forms without name collisions. Give each formset on a page a distinct
prefix (the factories pick a default: form for plain and model formsets, the reverse accessor name — books — for inline formsets).
Source: Understanding the ManagementForm
Plain formsets and the factory options
The options are shared by all three factories (some defaults differ, see the table):
class IsbnLineForm(forms.Form):
"""A plain (non-model) form: one ISBN to look up."""
isbn = forms.CharField(min_length=10, max_length=13)
IsbnFormSet = formset_factory(
IsbnLineForm, extra=2, min_num=1, validate_min=True, can_order=True, can_delete=True
)
| Option | Default (plain / model / inline) | Meaning |
|---|---|---|
|
|
Number of blank forms shown after the existing ones. Blank extra forms that the user leaves unchanged are ignored on save. |
|
|
Minimum number of forms to display (added to |
|
|
Maximum number of forms to display. A model formset still shows all existing rows even when there are more than |
|
|
Make |
|
|
Hard ceiling of forms that Django will instantiate from POST data, whatever |
|
|
Adds a |
|
|
Set |
|
|
Adds an |
|
|
Model and inline formsets only: never create new objects; extra forms are ignored (4.1). |
|
Detected |
Inline formsets only: name of the foreign key when the child has more than one foreign key to the parent. |
|
|
Base class to subclass for formset-wide |
|
|
The form class used for each row; pass your own ModelForm to keep its validation and widgets. |
|
- |
The same as the |
|
Project default |
The form renderer used for the formset and its forms (see Form Rendering & Widgets). |
The deletion_widget and ordering_widget class attributes (or the get_deletion_widget() and get_ordering_widget() class methods) choose the widgets of DELETE and ORDER; the
error_messages argument of the formset constructor overrides missing_management_form, too_many_forms and too_few_forms (the last two since 4.1).
Source: Formsets, Formset functions, modelformset_factory
Validation and the formset API
formset.is_valid() is True only when every form is valid (deleted forms and unchanged extra forms excluded), the management form is valid, validate_min/validate_max pass and formset.clean() raises nothing. After validation:
| Attribute / method | Content |
|---|---|
|
All forms: existing rows first, then the extra forms. |
|
A list with one error dict per form (empty dict for a valid form). |
|
Errors that do not belong to a form ( |
|
Form errors plus non-form errors. |
|
The two parts of |
|
An unbound, blank form whose prefix contains |
|
Forms marked for deletion. It is |
|
Valid forms sorted by |
|
List of |
|
|
def test_empty_extra_forms_are_valid_and_min_num_is_enforced(self):
data = formset_data(
"form",
[{"isbn": "9780441172719", "ORDER": "2"}, {"isbn": "1234567890", "ORDER": "1"}, {"isbn": ""}],
minimum=1,
)
formset = IsbnFormSet(data)
self.assertTrue(formset.is_valid(), formset.errors)
self.assertEqual([f.cleaned_data["isbn"] for f in formset.ordered_forms], ["1234567890", "9780441172719"])
self.assertEqual(formset.cleaned_data[2], {}) # the empty extra form
none = IsbnFormSet(formset_data("form", [{"isbn": ""}], minimum=1))
self.assertFalse(none.is_valid())
self.assertEqual(none.non_form_errors(), ["Please submit at least 1 form."])
def test_deleted_forms_and_error_counts(self):
data = formset_data("form", [{"isbn": "9780441172719", "DELETE": "on"}, {"isbn": "short"}], minimum=1)
formset = IsbnFormSet(data)
self.assertFalse(formset.is_valid())
self.assertEqual(formset.total_error_count(), 1)
self.assertEqual(formset.deleted_forms, []) # always empty while the formset is invalid
only = IsbnFormSet(formset_data("form", [{"isbn": "9780441172719", "DELETE": "on"}], minimum=1))
self.assertFalse(only.is_valid()) # deleted forms do not count towards min_num
self.assertEqual(only.non_form_errors(), ["Please submit at least 1 form."])
ok = IsbnFormSet(
formset_data("form", [{"isbn": "9780441172719", "DELETE": "on"}, {"isbn": "1234567890"}], minimum=1)
)
self.assertTrue(ok.is_valid(), ok.errors)
self.assertEqual(len(ok.deleted_forms), 1)
The second test records two behaviours that surprise people: deleted_forms is empty until the formset validates, and a form marked for deletion does not count towards min_num, so deleting the only form of a
validate_min formset is an error.
Formset-wide rules go in clean() of a formset subclass, as BookInlineFormSet does in Inline formsets: a parent and its children on one page. A rule that needs no other form belongs in the form’s own clean().
Rendering a formset
{{ formset }} renders the management form and every form with the formset template (div-based since 5.0). Spelling it out gives you control:
<h1>Books by {{ author }}</h1>
<form method="post">
{% csrf_token %}
{{ formset.management_form }}
{% if formset.non_form_errors %}
<ul class="errorlist nonform">{% for e in formset.non_form_errors %}<li>{{ e }}</li>{% endfor %}</ul>
{% endif %}
{% for form in formset %}
<fieldset id="{{ form.prefix }}">
{{ form }}
</fieldset>
{% endfor %}
<template id="empty-form">
<fieldset id="{{ formset.empty_form.prefix }}">{{ formset.empty_form }}</fieldset>
</template>
<button type="submit">Save</button>
</form>
Always render management_form and each form’s hidden fields (the {{ form }} shortcut includes them; if you render fields one by one, add {{ form.id }} and, for inline formsets, the foreign key field, yourself).
Forgetting a hidden id is a classic way to turn an edit into an insert, or to make every row look new.
Model formsets: edit a QuerySet in bulk
modelformset_factory(model, form=ModelForm, formset=BaseModelFormSet, extra=1, can_delete=False, can_order=False, max_num=None, fields=None, exclude=None, …, edit_only=False) builds a formset whose forms are bound to the objects of a queryset. A
model formset adds a hidden primary-key field to every form: that field is how the POST says which row each form edits.
PriceFormSet = modelformset_factory(
Book,
fields=["price"],
extra=0,
edit_only=True,
)
edit_only=True plus extra=0 gives a spreadsheet-style editor for existing rows: no way to create rows through it. The view passes an explicit queryset; without one the formset uses Model._default_manager, which is every row.
@login_required
def price_list(request):
"""Model formset limited to existing rows (edit_only=True)."""
queryset = Book.objects.select_related("author").order_by("title")
if request.method == "POST":
formset = PriceFormSet(request.POST, queryset=queryset)
if formset.is_valid():
formset.save()
return redirect("catalog:price-list")
else:
formset = PriceFormSet(queryset=queryset)
return render(request, "catalog/price_list.html", {"formset": formset})
A few behaviours worth knowing, each covered by a test:
-
The queryset is ordered. If it is not totally ordered the formset adds the primary key, so that the form order is the same on GET and on POST. Use an explicit
order_by(). -
Only rows in the queryset can be edited. A posted primary key that is not in the queryset does not raise an error and does not touch the row: the form is built as an empty object and
save()skips it. That is the right security behaviour (no writes outside the queryset), but it means the POST is silently accepted; filter the queryset by the user to make "not yours" behave as "not there". -
save()returns the objects it wrote and records the rest:formset.new_objects,formset.changed_objects(a list of(object, changed_field_names)) andformset.deleted_objects. -
Uniqueness across forms.
BaseModelFormSet.clean()callsvalidate_unique()for the whole set, so two forms that use the sameslugproduce "Please correct the duplicate data for slug". Subclasses that overrideclean()must callsuper().clean().
def test_edit_only_ignores_extra_forms(self):
qs = Book.objects.order_by("title")
rows = [{"id": b.pk, "price": "2.50"} for b in self.books]
rows.append({"price": "9"}) # an extra, new-object row
formset = PriceFormSet(formset_data("form", rows, initial=3), queryset=qs)
self.assertTrue(formset.is_valid(), formset.errors)
formset.save()
self.assertEqual(Book.objects.count(), 3) # nothing created
self.assertEqual(set(Book.objects.values_list("price", flat=True)), {Decimal("2.50")})
def test_row_outside_queryset_is_ignored(self):
qs = Book.objects.filter(pk=self.books[0].pk)
rows = [{"id": self.books[1].pk, "price": "99"}]
formset = PriceFormSet(formset_data("form", rows, initial=1), queryset=qs)
self.assertTrue(formset.is_valid()) # not an error: the row is just not "initial"
self.assertEqual(formset.save(), [])
self.assertEqual(Book.objects.get(pk=self.books[1].pk).price, Decimal("1.00"))
The second test records the actual behaviour: the formset is valid, save() returns an empty list and the row is unchanged.
formset.save(commit=False) for model and inline formsets
formset.save(commit=False) returns the list of new and changed instances without writing them, like the form version. Three consequences:
-
You save each returned instance yourself (setting server-controlled fields first), then call
formset.save_m2m()if the forms have many-to-many fields. -
Deletions are not performed. Objects marked for deletion are listed in
formset.deleted_objectsbut only deleted whencommit=True; withcommit=Falseyou delete them yourself (for obj in formset.deleted_objects: obj.delete()). -
The return value contains the added and changed objects only; unchanged rows are not included.
The review view in Inline formsets: a parent and its children on one page follows exactly this pattern.
Source: Saving objects in the formset
Inline formsets: a parent and its children on one page
inlineformset_factory(parent_model, model, form=ModelForm, formset=BaseInlineFormSet, fk_name=None, fields=None, exclude=None, extra=3, can_order=False, can_delete=True, max_num=None, …, edit_only=False)
builds a model formset for the children of one parent. You pass the parent as instance= when instantiating it. Django finds the foreign key, restricts the queryset to
child.fk == instance, adds the foreign key to each form as a hidden, validated field (so the child cannot be pointed at another parent through the POST), and sets the parent on new children when saving. If
model has several foreign keys to parent_model, pass fk_name; otherwise Django raises a ValueError naming the ambiguity.
class BookInlineFormSet(BaseInlineFormSet):
"""Books of one author; refuses two forms with the same title."""
def clean(self):
super().clean() # runs the unique checks across the forms
if any(self.errors):
return
titles = [
f.cleaned_data["title"].casefold()
for f in self.forms
if f.cleaned_data and not f.cleaned_data.get("DELETE")
]
if len(titles) != len(set(titles)):
raise forms.ValidationError("Two books of one author cannot share a title.")
AuthorBooksFormSet = inlineformset_factory(
Author,
Book,
form=BookForm,
formset=BookInlineFormSet,
fk_name="author",
fields=["title", "slug", "isbn", "price", "published"],
extra=2,
max_num=5,
validate_max=True,
can_delete=True,
)
BookInlineFormSet.clean() calls super().clean() first (to keep the uniqueness checks), skips itself when any form already has errors, and then compares titles across the whole set: that is the typical use of a
formset subclass. max_num=5 with validate_max=True caps an author at five books, and can_delete=True adds the delete checkbox.
@login_required
def author_books(request, slug):
"""Inline formset: an author and their books on one page."""
author = get_object_or_404(Author, slug=slug)
if request.method == "POST":
formset = AuthorBooksFormSet(request.POST, instance=author)
if formset.is_valid():
with transaction.atomic():
formset.save()
messages.success(request, "Books saved.")
return redirect("catalog:author-books", slug=author.slug)
else:
formset = AuthorBooksFormSet(instance=author)
return render(
request, "catalog/author_books.html", {"author": author, "formset": formset}
)
The view wraps formset.save() in transaction.atomic() because one POST can insert, update and delete several rows, and a failure in the third should not leave the first two applied.
def test_add_edit_and_delete_in_one_post(self):
data = formset_data(
"books",
[
{"id": self.dune.pk, "author": self.author.pk, "title": "Dune (1st ed.)",
"slug": "dune", "isbn": "9780441172719", "price": "10.00", "published": "1965-08-01"},
{"title": "Messiah", "slug": "messiah", "isbn": "9780441172696",
"price": "8.00", "published": "1969-01-01"},
],
initial=1, maximum=5,
)
formset = AuthorBooksFormSet(data, instance=self.author)
self.assertTrue(formset.is_valid(), formset.errors)
saved = formset.save()
self.assertEqual(len(saved), 2)
self.assertEqual(formset.changed_objects[0][1], ["title", "price"])
self.assertEqual([b.title for b in formset.new_objects], ["Messiah"])
self.assertEqual(Book.objects.get(slug="messiah").author, self.author) # FK set by the formset
data = formset_data(
"books",
[{"id": self.dune.pk, "author": self.author.pk, "title": "Dune", "slug": "dune",
"isbn": "9780441172719", "price": "10.00", "published": "1965-08-01", "DELETE": "on"}],
initial=1, maximum=5,
)
formset = AuthorBooksFormSet(data, instance=self.author)
self.assertTrue(formset.is_valid(), formset.errors)
formset.save()
self.assertEqual([b.slug for b in formset.deleted_objects], ["dune"])
self.assertFalse(Book.objects.filter(slug="dune").exists())
def test_default_prefix_and_empty_form(self):
formset = AuthorBooksFormSet(instance=self.author)
self.assertEqual(formset.prefix, "books") # the related_name
self.assertEqual(formset.initial_form_count(), 1)
self.assertEqual(len(formset.forms), 3) # 1 existing + extra=2
self.assertEqual(formset.empty_form.prefix, "books-__prefix__")
self.assertIn('name="books-TOTAL_FORMS"', str(formset.management_form))
"Add another" with JavaScript. The server renders extra blank forms; to add rows without a round trip, clone formset.empty_form. The template above puts it inside a <template> element. The script replaces __prefix__ with the current TOTAL_FORMS and increments TOTAL_FORMS:
const total = document.querySelector('#id_books-TOTAL_FORMS');
document.querySelector('#add-row').addEventListener('click', () => {
const index = Number(total.value);
const html = document.querySelector('#empty-form').innerHTML.replaceAll('__prefix__', index);
document.querySelector('#rows').insertAdjacentHTML('beforeend', html);
total.value = index + 1; // never exceed MAX_NUM_FORMS in the UI; the server enforces it anyway
});
(The markup needs a <button type="button" id="add-row"> and a wrapper with id="rows"; the snippet is the JavaScript half of a pattern, and the server half is tested above.) Never let the client lower INITIAL_FORMS; the server decides which rows exist.
Source: Inline formsets, inlineformset_factory, empty_form
Passing extra arguments to every form: form_kwargs and get_form_kwargs()
A formset constructs each form for you, so it must also forward your extra constructor arguments. There are two spellings:
-
form_kwargs={"user": request.user}when you instantiate the formset (since 1.9) — the same dict is passed to every form, includingempty_form. -
get_form_kwargs(self, index)on a formset subclass — Django calls it for every form and forempty_form(withindex=None), so the arguments can differ per row. Return a new dictionary based onsuper().get_form_kwargs(index).
UserReviewFormSet takes the user in its own constructor and hands it down, so the view only passes user=request.user:
class UserReviewFormSet(BaseInlineFormSet):
"""Alternative to form_kwargs=...: the formset owns the user and hands it down."""
def __init__(self, *args, user, **kwargs):
self.user = user
super().__init__(*args, **kwargs)
def get_form_kwargs(self, index):
# index is None for empty_form; the dict is per form.
kwargs = super().get_form_kwargs(index)
kwargs["user"] = self.user
return kwargs
ReviewFormSet = inlineformset_factory(
Book,
Review,
form=ReviewForm,
formset=UserReviewFormSet,
fields=["reviewer", "rating", "body"],
extra=1,
can_delete=True,
)
@login_required
def book_reviews(request, slug):
"""Inline formset whose forms receive request.user, saved with commit=False."""
book = get_object_or_404(Book, slug=slug)
mine = book.reviews.filter(reviewer=request.user)
if request.method == "POST":
formset = ReviewFormSet(
request.POST, instance=book, queryset=mine, user=request.user
)
if formset.is_valid():
reviews = formset.save(commit=False)
for review in reviews:
review.reviewer = request.user # belt and braces; the form already did
review.save()
for review in formset.deleted_objects:
review.delete() # commit=False does not delete by itself
return redirect(book)
else:
formset = ReviewFormSet(instance=book, queryset=mine, user=request.user)
return render(request, "catalog/book_reviews.html", {"book": book, "formset": formset})
The view shows the whole pattern of a user-owned inline formset: the queryset is limited to the user’s reviews, the user reaches every form, commit=False lets the view stamp the reviewer, and deletions are applied by hand.
def test_get_form_kwargs_reaches_every_form_and_the_empty_form(self):
formset = ReviewFormSet(instance=self.book, user=self.ann)
self.assertTrue(all(f.user == self.ann for f in formset.forms))
self.assertEqual(formset.empty_form.user, self.ann)
def test_form_kwargs_shorthand_on_a_plain_formset(self):
from django.forms import inlineformset_factory
Plain = inlineformset_factory(Book, Review, form=ReviewForm, fields=["rating", "body"])
formset = Plain(instance=self.book, form_kwargs={"user": self.bob})
self.assertEqual(formset.forms[0].user, self.bob)
def test_commit_false_view_flow(self):
self.client.force_login(self.ann)
url = reverse("catalog:book-reviews", args=["b"])
data = formset_data("reviews", [{"rating": "5", "body": "Loved it"}], initial=0, maximum=1000)
response = self.client.post(url, data)
self.assertRedirects(response, self.book.get_absolute_url())
review = Review.objects.get()
self.assertEqual((review.reviewer, review.book, review.rating), (self.ann, self.book, 5))
# Bob cannot see or edit ann's review: the queryset is scoped to the user.
self.client.force_login(self.bob)
data = formset_data("reviews", [{"id": review.pk, "book": self.book.pk, "rating": "1"}], initial=1)
response = self.client.post(url, data)
self.assertEqual(response.status_code, 302) # valid, but the row is ignored
review.refresh_from_db()
self.assertEqual((review.rating, review.reviewer), (5, self.ann))
# Ann deletes hers; with commit=False the view deletes deleted_objects itself.
self.client.force_login(self.ann)
data = formset_data("reviews", [{"id": review.pk, "book": self.book.pk, "rating": "5", "DELETE": "on"}], initial=1)
self.client.post(url, data)
self.assertFalse(Review.objects.exists())
Note what the second half of test_commit_false_view_flow records: when Bob posts Ann’s review id, nothing happens to Ann’s review, and the POST is accepted (302). Rows outside the queryset are ignored, not refused.
Files and formsets
A formset with file fields (an ImageField per child, for example) needs the same two things as a single form: enctype="multipart/form-data" on the <form> and request.FILES as the second argument, AuthorBooksFormSet(request.POST, request.FILES, instance=author).
Forgetting request.FILES makes every file field look empty. The storage side is on File Uploads & Storage.
Source: File uploads
The admin uses the same classes
InlineModelAdmin (TabularInline, StackedInline) is an inline formset with an admin template, and ModelAdmin.get_formset_kwargs() (Django 4.0) is the admin’s get_form_kwargs. When an admin inline is enough for staff,
use it instead of writing views; see The Admin Site.
Source: InlineModelAdmin objects
Sync and async behaviour
Forms and formsets have no async API: there is no await form.ais_valid() and no async save(). The code is synchronous, and the ORM calls it contains are blocked in an async context. In practice:
-
In a regular
defview nothing changes, under WSGI or ASGI. Django runs sync views in a thread under ASGI. -
In an
async defview, validation that touches the database (ModelChoiceField,validate_unique(),validate_constraints()) and rendering a field backed by a queryset raiseSynchronousOnlyOperation. Wrap them withsync_to_async(). -
form.save(commit=False)touches no database, so it is safe to call directly; then use the async ORM for the write (await book.asave()), and replaceform.save_m2m()with the relation’s async method (await book.genres.aset(…)). -
A view that does a lot of form work is usually simpler as a sync view; reserve
async deffor views that really await I/O.
async def book_create_async(request):
"""Async view: form code is synchronous, so the DB-touching calls are wrapped."""
user = await request.auser()
if not user.is_authenticated:
return HttpResponse(status=401)
form = BookForm(request.POST or None)
if request.method == "POST":
# is_valid() runs ModelChoiceField and unique queries: synchronous ORM.
if await sync_to_async(form.is_valid)():
book = form.save(commit=False) # touches no database
book.added_by = user
await book.asave()
await book.genres.aset(form.cleaned_data["genres"]) # instead of save_m2m()
return HttpResponse(f"created {book.slug}", status=201)
return HttpResponse(form.errors.as_json(), status=400, content_type="application/json")
return HttpResponse("POST only", status=405)
SynchronousOnlyOperation - You cannot call this from an async context - use a thread or sync_to_async.
<select name="author" required id="id_author"> <option val
SynchronousOnlyOperation - You cannot call this from an async context - use a thread or sync_to_async.
(The three lines are: rendering the field directly, rendering it through sync_to_async, and calling is_valid() directly on a form that has a valid author id.)
async def test_async_view(self):
author = await Author.objects.acreate(name="A", slug="a")
genre = await Genre.objects.acreate(name="SF")
user = await User.objects.acreate_user("ann", password="s3cret-pass-1")
await self.async_client.aforce_login(user)
response = await self.async_client.post(
reverse("catalog:book-create-async"),
book_data(author=author.pk, genres=[genre.pk]),
)
self.assertEqual(response.status_code, 201)
book = await Book.objects.select_related("added_by").aget(slug="dune")
self.assertEqual(book.added_by, user)
self.assertEqual([g async for g in book.genres.all()], [genre])
bad = await self.async_client.post(reverse("catalog:book-create-async"), book_data(author=author.pk, isbn="x"))
self.assertEqual(bad.status_code, 400)
See Async Support for the general rules of sync and async code in Django.
Source: Asynchronous support, Asynchronous queries
Running the examples
All the code above is one app (catalog), so it can be exercised three ways.
Checks and the test suite. manage.py check finds configuration mistakes before a request does, and the tests assert behaviour: status codes, redirects, database side effects, which rows a user may touch.
python manage.py check && python manage.py test catalogSystem check identified no issues (0 silenced).
Creating test database for alias 'default'...
................................
----------------------------------------------------------------------
Ran 32 tests in 0.980s
OK
Destroying test database for alias 'default'...
Found 32 test(s).
System check identified no issues (0 silenced).
The tests build formset POST data with a small helper that writes the management form, which is also the best way to see what a browser sends:
def book_data(**extra):
data = {
"title": "Dune",
"slug": "dune",
"isbn": "9780441172719",
"price": "9.99",
"published": "1965-08-01",
}
data.update(extra)
return data
def formset_data(prefix, forms_data, initial=0, minimum=0, maximum=1000):
data = {
f"{prefix}-TOTAL_FORMS": str(len(forms_data)),
f"{prefix}-INITIAL_FORMS": str(initial),
f"{prefix}-MIN_NUM_FORMS": str(minimum),
f"{prefix}-MAX_NUM_FORMS": str(maximum),
}
for i, row in enumerate(forms_data):
for key, value in row.items():
data[f"{prefix}-{i}-{key}"] = value
return data
A curl session. With runserver on a local port, an authenticated session cookie and a CSRF token, the inline formset can be driven without a browser. The first command fetches the page
and lists the hidden inputs the formset rendered; the second adds a book and changes a price in one POST; the third sends two books with the same title; the fourth leaves out the CSRF token.
… marks omitted lines and arguments)$ curl -s -b cookies.txt -c cookies.txt http://127.0.0.1:8136/catalog/authors/herbert/books/ | grep -o '<input type="hidden"[^>]*>'
<input type="hidden" name="csrfmiddlewaretoken" value="...">
<input type="hidden" name="books-TOTAL_FORMS" value="3" id="id_books-TOTAL_FORMS">
<input type="hidden" name="books-INITIAL_FORMS" value="1" id="id_books-INITIAL_FORMS">
<input type="hidden" name="books-MIN_NUM_FORMS" value="0" id="id_books-MIN_NUM_FORMS">
<input type="hidden" name="books-MAX_NUM_FORMS" value="5" id="id_books-MAX_NUM_FORMS">
<input type="hidden" name="books-0-id" value="1" id="id_books-0-id">
<input type="hidden" name="books-0-author" value="1" id="id_books-0-author">
<input type="hidden" name="books-1-id" id="id_books-1-id">
...
$ curl ... -X POST .../authors/herbert/books/ -d books-TOTAL_FORMS=2 -d books-INITIAL_FORMS=1 ... -d books-0-price=10.50 -d books-1-title=Messiah ...
302 -> http://127.0.0.1:8136/catalog/authors/herbert/books/
$ curl ... -X POST ... (two forms titled "Same" and "same")
<ul class="errorlist nonform"><li>Two books of one author cannot share a title.</li></ul>
$ curl -X POST ... (no csrfmiddlewaretoken)
403
[('dune', Decimal('10.50')), ('messiah', Decimal('8.00'))] <- database state after the first POST
The 403 is the CSRF middleware refusing the POST before the view ran; the last line is the content of the table afterwards (printed from manage.py shell).
from django.urls import path
from . import views
app_name = "catalog"
urlpatterns = [
path("books/new/", views.BookCreateView.as_view(), name="book-create"),
path("books/<slug:slug>/", views.BookDetailView.as_view(), name="book-detail"),
path("books/<slug:slug>/edit/", views.book_update, name="book-update"),
path("books/<slug:slug>/reviews/", views.book_reviews, name="book-reviews"),
path("reviews/new/", views.ReviewCreateView.as_view(), name="review-create"),
path("authors/<slug:slug>/books/", views.author_books, name="author-books"),
path("prices/", views.price_list, name="price-list"),
path("async/books/new/", views.book_create_async, name="book-create-async"),
path("", views.price_list, name="index"),
]
Source: Testing tools, django-admin check
Pitfalls
| Pitfall | Symptom | Fix |
|---|---|---|
|
A newly added model field becomes editable from the browser without anyone noticing. |
List the fields explicitly; review the list when the model changes. |
Forgetting |
The row is saved, the many-to-many choices are lost, no error. |
Call |
Setting a field after |
An invalid value reaches the database, or an |
Keep the field in the form as |
A constraint over a field that is not in the form |
|
Include the field (disabled), or catch the error and add a form error. |
Overriding |
|
Call |
A ModelForm subclass with its own |
|
|
Missing |
"ManagementForm data is missing or has been tampered with". |
Render |
Missing hidden |
Edits become inserts, or rows are not matched. |
Render |
Missing or reused |
One formset reads the other’s data. |
Give every formset a unique |
Deleting the only form of a |
"Please submit at least N forms." |
Deleted forms do not count; keep one or lower |
Reading |
An empty list; or rows that are still there. |
Validate first; with |
Trusting |
A forged |
Set |
Assuming a posted primary key outside the queryset is an error |
The POST is accepted and the row is silently ignored. |
Scope the queryset to the user; do not rely on an error to detect tampering. |
Using |
|
Use |
Calling forms from |
|
Use a sync view, or wrap |
No |
File inputs always look empty. |
Pass |
Reusing |
The object shows unsaved, half-edited values. |
Reload it from the database before using it again. |
Source: Creating forms from models, Formsets
Where the books are outdated
The four books that informed this section were written against Django 1.11 to 2.1. For ModelForms and formsets the ideas survived; the details that changed are the ones that bite when you copy a listing:
-
fields. Old listings writeclass Meta: model = Bookwith nofields, orfields = '__all__'for convenience. Omitting both has been anImproperlyConfigurederror since 1.8, and__all__is a mass-assignment risk, not a shortcut. -
Saving formsets with
commit=False. A listing that stops after saving the returned objects misses two things that are then your job: deletions and many-to-many data (see Model formsets: edit a QuerySet in bulk). -
TOTAL_FORMSforging. Code written for Django 1.11 to 2.1 predatesabsolute_max(3.2) and the user-facing management-form error (3.2); both close tampering and resource-exhaustion gaps. -
edit_only. Before 4.1 the usual way to make a model formset edit-only wasextra=0, which still accepted forged extra forms;edit_only=True(4.1) enforces it on the server. -
Rendering. Books render forms with
as_p/as_table(); since 4.0 forms and formsets render through the template engine, 4.1 addedas_div()and in 5.0 the div layout became the default. Custom renderers namedDjangoDivFormRendererandJinja2DivFormRenderer(the 4.1 transition aids) were removed in 6.0. -
url(),render_to_response,ugettextaround form views were removed in 3.0 and 4.0; usepath(),render(),gettext. -
Tests that only check
status_code == 200. A form POST that re-renders the form with errors is also a 200. Assert the redirect (302) or the database row, as the tests above do. -
request.user.is_authenticated()as a call was removed in 2.0; and an owner field posted from a hidden input in old listings is a forgery risk (use a disabled field orcommit=False).
What changed recently
| Django | Change relevant to ModelForms and formsets |
|---|---|
6.1 |
New |
6.0 |
The transitional |
5.2 |
|
5.0 |
Div-based rendering became the default for forms and formsets; field groups ( |
4.2 |
|
4.1 |
|
4.0 |
Forms and formsets render through the template engine; |
3.2 |
|
Django 5.2 is the current LTS (security fixes until April 2028) and 6.1 the latest release; every API on this page exists in both, except where the table gives a later version.