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:

catalog/models.py
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.

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.

flowchart TD req["POST request
(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.

flowchart TD post["POST body with prefix-TOTAL_FORMS, prefix-INITIAL_FORMS,
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.

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:

Output of the misconfiguration shell script (a ModelForm without 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.

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__" and exclude = […​] are deny-lists in disguise. The day someone adds is_staff, price_override, added_by or published to 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 fields list 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:

Test excerpt: a posted value for a field that is not in Meta.fields is ignored (class BookFormTests)
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

CharField, SlugField, EmailField, URLField, UUIDField

The matching form field (CharField keeps max_length; SlugField becomes SlugField)

TextField

CharField with a Textarea widget

IntegerField, PositiveSmallIntegerField and friends, FloatField, DecimalField

IntegerField, FloatField, DecimalField (with max_digits and decimal_places)

BooleanField, DateField, DateTimeField, TimeField, DurationField, JSONField

The matching form field

FileField, ImageField

FileField, ImageField (the form needs request.FILES and enctype="multipart/form-data")

A field with choices

TypedChoiceField

ForeignKey, OneToOneField

ModelChoiceField (a <select> over a QuerySet)

ManyToManyField

ModelMultipleChoiceField (a multiple <select>)

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:

Output of 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

model

The model class. Required; without it Django raises ValueError: ModelForm has no model class specified when the form is instantiated.

fields

Allow-list of model field names to include, or "__all__". The order is the display order.

exclude

Deny-list of names to leave out. Usable instead of fields, but see Always list the fields explicitly (never __all__, avoid exclude); if both are given, exclude still removes names from fields.

widgets

{"field": WidgetClassOrInstance} — replace the default widget (forms.DateInput(attrs={"type": "date"}), forms.CheckboxSelectMultiple).

labels, help_texts

{"field": "text"} — override the label or help text taken from the model.

error_messages

{"field": {"error_code": "message"}} — override messages such as required, invalid, unique, max_length.

field_classes

{"field": FormFieldClass} — use another form field class for a model field (for example forms.SlugField).

localized_fields

List of names (or "__all__") whose values are rendered and parsed using the active locale.

formfield_callback

A callable (model_field, **kwargs) returning a form field; lets you customise all fields in one place. Accepted in Meta since Django 4.2.

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):

catalog/forms.py (BookForm)
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:

Test excerpt (class BookFormTests)
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.

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.

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.instance is a fresh Book() and save() will INSERT.

  • BookForm(request.POST, instance=book) — edits book in place; save() will UPDATE the same row. The form fields start with the instance’s values (through model_to_dict()).

  • BookForm(initial={"author": author}) — pre-fills fields. initial wins 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.

Test excerpt (class BookFormTests): an update changes the row in place and creates no second row
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.

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:

  1. construct_instance() copies cleaned_data onto form.instance for every field in fields.

  2. instance.full_clean(exclude=…​, validate_unique=False, validate_constraints=False) runs the model’s field validators and Model.clean().

  3. If BaseModelForm.clean() ran (see the pitfall below), validate_unique() checks every unique=True and unique_together rule with a query, and validate_constraints() checks the model’s Meta.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 when reviewer is 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 raises IntegrityError on save.

  • Overriding clean() without calling super().clean() stops validate_unique() and validate_constraints() from running (the flags are set in BaseModelForm.clean()).

Test excerpt (class ReviewFormSetTests): a constraint that involves an excluded field is not validated
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.

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:

Output of the shell script (an invalid form)
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.

Test excerpt (class BookFormTests): nothing is written until you write it, and many-to-many data needs save_m2m()
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 None for 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, because full_clean() already ran. Validate the values you set yourself, or use the disabled-field technique below.

  • save() carries alters_data = True, so templates cannot call it by accident.

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.

catalog/views.py (book_update)
@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:

catalog/views.py (BookCreateView and ReviewCreateView)
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.

Test excerpt (class ViewsTests)
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).

catalog/forms.py (ReviewForm)
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).

Test excerpt (class ReviewFormSetTests)
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.

catalog/templates/catalog/book_form.html
<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

formset_factory(form, …​)

A formset of plain Form objects (BaseFormSet).

modelformset_factory(model, …​)

A formset of ModelForms over a QuerySet (BaseModelFormSet): edit existing rows and add new ones.

inlineformset_factory(parent_model, model, …​)

A model formset limited to the rows that point at one parent instance (BaseInlineFormSet): "an author and their books".

generic_inlineformset_factory(model, …​)

The same for a generic relation (django.contrib.contenttypes.forms); see ContentTypes & Generic Relations.

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):

Test excerpt (class InlineFormSetTests)
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).

Plain formsets and the factory options

The options are shared by all three factories (some defaults differ, see the table):

catalog/forms.py (a plain formset)
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

extra

1 / 1 / 3

Number of blank forms shown after the existing ones. Blank extra forms that the user leaves unchanged are ignored on save.

min_num

0

Minimum number of forms to display (added to extra); enforced only with validate_min=True.

max_num

1000

Maximum number of forms to display. A model formset still shows all existing rows even when there are more than max_num. Enforced only with validate_max=True.

validate_min, validate_max

False

Make min_num/max_num validation errors (Please submit at least N forms. / Please submit at most N forms.). Forms marked for deletion do not count; unchanged extra forms do not count towards the minimum.

absolute_max

max_num + 1000

Hard ceiling of forms that Django will instantiate from POST data, whatever TOTAL_FORMS says (3.2). It protects against a forged huge TOTAL_FORMS that would make the server build millions of forms; above it validation fails with too_many_forms.

can_delete

False / False / True

Adds a DELETE checkbox to each form. Marked forms are skipped by validation and deleted by save().

can_delete_extra

True

Set False to show the DELETE checkbox only on rows that already exist (3.2).

can_order

False

Adds an ORDER field to each form; formset.ordered_forms returns the forms sorted by it.

edit_only

False

Model and inline formsets only: never create new objects; extra forms are ignored (4.1).

fk_name

Detected

Inline formsets only: name of the foreign key when the child has more than one foreign key to the parent.

formset

BaseFormSet / BaseModelFormSet / BaseInlineFormSet

Base class to subclass for formset-wide clean() or a custom get_form_kwargs().

form

Form / ModelForm / ModelForm

The form class used for each row; pass your own ModelForm to keep its validation and widgets.

fields, exclude, widgets, labels, help_texts, error_messages, field_classes, localized_fields, formfield_callback

-

The same as the Meta options in Every Meta option, applied to the form generated for each row. fields has the same security rule: list them.

renderer

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).

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

formset.forms

All forms: existing rows first, then the extra forms.

formset.errors

A list with one error dict per form (empty dict for a valid form).

formset.non_form_errors()

Errors that do not belong to a form (too_many_forms, missing_management_form, errors raised in formset.clean()). Rendered with the extra CSS class nonform since 4.0.

formset.total_error_count()

Form errors plus non-form errors.

formset.initial_forms, formset.extra_forms

The two parts of forms.

formset.empty_form

An unbound, blank form whose prefix contains __prefix__; used as the template that JavaScript clones to add a row (see Inline formsets: a parent and its children on one page).

formset.deleted_forms

Forms marked for deletion. It is [] while the formset is invalid and when can_delete is false (the next test shows it).

formset.ordered_forms

Valid forms sorted by ORDER (needs can_order=True; deleted forms are removed).

formset.cleaned_data

List of cleaned_data dicts; the dict of an unchanged extra form is empty. Available only after is_valid() returned True.

formset.has_changed()

True if any form differs from its initial data.

Test excerpt (class PlainFormSetTests)
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:

catalog/templates/catalog/author_books.html
<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.

catalog/forms.py (PriceFormSet)
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.

catalog/views.py (price_list)
@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)) and formset.deleted_objects.

  • Uniqueness across forms. BaseModelFormSet.clean() calls validate_unique() for the whole set, so two forms that use the same slug produce "Please correct the duplicate data for slug". Subclasses that override clean() must call super().clean().

Test excerpt (class ModelFormSetTests)
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_objects but only deleted when commit=True; with commit=False you 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.

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.

catalog/forms.py (the author-books inline formset)
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.

catalog/views.py (author_books)
@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.

Test excerpt (class InlineFormSetTests): add, edit and delete in a single POST
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:

static/catalog/add-row.js
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.

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, including empty_form.

  • get_form_kwargs(self, index) on a formset subclass — Django calls it for every form and for empty_form (with index=None), so the arguments can differ per row. Return a new dictionary based on super().get_form_kwargs(index).

UserReviewFormSet takes the user in its own constructor and hands it down, so the view only passes user=request.user:

catalog/forms.py (UserReviewFormSet and ReviewFormSet)
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,
)
catalog/views.py (book_reviews)
@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.

Test excerpt (class ReviewFormSetTests)
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.

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 def view nothing changes, under WSGI or ASGI. Django runs sync views in a thread under ASGI.

  • In an async def view, validation that touches the database (ModelChoiceField, validate_unique(), validate_constraints()) and rendering a field backed by a queryset raise SynchronousOnlyOperation. Wrap them with sync_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 replace form.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 def for views that really await I/O.

catalog/views.py (book_create_async)
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)
Output of the async shell script (queryset-backed field rendered in async code, then through sync_to_async)
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.)

Test excerpt (class AsyncViewTests): the async view runs under the async test client
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.

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.

Output of python manage.py check && python manage.py test catalog
System 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:

catalog/tests.py (helpers)
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.

Output of the curl session (abbreviated; …​ 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).

catalog/urls.py
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"),
]

Pitfalls

Pitfall Symptom Fix

fields = "__all__" or exclude on a form that users submit

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 save_m2m() after save(commit=False)

The row is saved, the many-to-many choices are lost, no error.

Call form.save_m2m() (or formset.save_m2m()) after saving the instances.

Setting a field after commit=False that has model validation

An invalid value reaches the database, or an IntegrityError appears instead of a form error.

Keep the field in the form as disabled, or validate it by hand before saving.

A constraint over a field that is not in the form

is_valid() is True and save() raises IntegrityError.

Include the field (disabled), or catch the error and add a form error.

Overriding clean() without super().clean()

unique and constraint checks silently stop running.

Call super().clean() first; same for BaseFormSet.clean() subclasses.

A ModelForm subclass with its own Meta that does not inherit

ImproperlyConfigured or lost widgets.

class Meta(Parent.Meta): …​

Missing management_form in the template

"ManagementForm data is missing or has been tampered with".

Render {{ formset.management_form }} inside the <form>.

Missing hidden id or foreign key field when rendering fields by hand

Edits become inserts, or rows are not matched.

Render {{ form }} or the hidden fields explicitly.

Missing or reused prefix when two formsets share a page

One formset reads the other’s data.

Give every formset a unique prefix.

Deleting the only form of a validate_min formset

"Please submit at least N forms."

Deleted forms do not count; keep one or lower min_num.

Reading deleted_forms before is_valid(), or with commit=False expecting deletions

An empty list; or rows that are still there.

Validate first; with commit=False delete formset.deleted_objects yourself.

Trusting MAX_NUM_FORMS from the browser

A forged TOTAL_FORMS makes the server build thousands of forms.

Set max_num and validate_max; keep absolute_max low.

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 request.user as type(user)

AttributeError: type object 'SimpleLazyObject' has no attribute '_default_manager'.

Use get_user_model().

Calling forms from async def without sync_to_async()

SynchronousOnlyOperation.

Use a sync view, or wrap is_valid() and rendering.

No request.FILES or enctype for file fields

File inputs always look empty.

Pass request.FILES and set enctype="multipart/form-data".

Reusing instance after an invalid POST

The object shows unsaved, half-edited values.

Reload it from the database before using it again.

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 write class Meta: model = Book with no fields, or fields = '__all__' for convenience. Omitting both has been an ImproperlyConfigured error 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_FORMS forging. Code written for Django 1.11 to 2.1 predates absolute_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 was extra=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 added as_div() and in 5.0 the div layout became the default. Custom renderers named DjangoDivFormRenderer and Jinja2DivFormRenderer (the 4.1 transition aids) were removed in 6.0.

  • url(), render_to_response, ugettext around form views were removed in 3.0 and 4.0; use path(), 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 or commit=False).

What changed recently

Django Change relevant to ModelForms and formsets

6.1

New Stylesheet asset object for form media and the BLANK_CHOICE_LABEL constant with the transitional USE_BLANK_CHOICE_DASH setting that controls the default blank choice label in form choices; the admin change forms, including inlines, were re-laid out for accessibility. Calendar versioning starts (RemovedInDjango2028Warning).

6.0

The transitional DjangoDivFormRenderer and Jinja2DivFormRenderer were removed; forms.URLField now assumes https for values typed without a scheme (the FORMS_URLFIELD_ASSUME_HTTPS setting is gone).

5.2

Form.bound_field_class and ErrorList.field_id for simpler customisation of BoundField and error markup.

5.0

Div-based rendering became the default for forms and formsets; field groups (as_field_group) appeared; the undocumented BaseModelFormSet.save_existing() renamed its instance argument to obj (only matters if you override it with a keyword call).

4.2

Meta.formfield_callback on ModelForms; modelform_factory() respects it.

4.1

edit_only for modelformset_factory() and inlineformset_factory(); too_few_forms and too_many_forms can be overridden through error_messages; model constraints validated by ModelForms through Model.validate_constraints(); as_div().

4.0

Forms and formsets render through the template engine; deletion_widget and get_deletion_widget(); nonform CSS class on non-form errors; ModelAdmin.get_formset_kwargs().

3.2

absolute_max and can_delete_extra for all three factories; a missing management form is a validation error, not an exception.

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.