skip to content

With Django's modelformset_factory, how do you stop a bulk-edit page from changing rows outside the user's scope or creating rows it never offered?

level: seniorimportance: should knowfreq 28%

answer

  1. what the default queryset contains
  2. posted primary keys are client input
  3. scope on POST, not only GET
  4. extra=0 is only a display setting
  5. an argument added in 4.1

basics

~20 s

Pass a user-scoped queryset to the model formset on both GET and POST, because without one it covers every row of the model; and use edit_only=True, since extra=0 or max_num does not stop a client posting new rows.

solid answer

~50 s

A model formset instantiated without `queryset=` uses the model's default manager — every row in the table. On a POST it matches each initial form's posted primary key against **its own queryset**, so the scoping line is the queryset you pass on POST: scope it on GET only and a user can edit another customer's rows by changing the hidden `id` values. With a scoped queryset, a key for a row outside it is not matched and `save()` skips that form. Creation is a separate hole: `extra=0` and `max_num` only shape rendering, so a client can raise `TOTAL_FORMS` and post new rows. `edit_only=True` (Django 4.1+) makes `save()` ignore new forms; `validate_max=True` turns `max_num` into a real limit, and `absolute_max` caps how many forms are instantiated at all. Inline formsets scope themselves to their parent — but only to the parent you load.

code

python · 22 lines
python
from django.forms import modelformset_factory
from django.shortcuts import redirect, render

from .models import OrderLine

LineQuantityFormSet = modelformset_factory(
    OrderLine, fields=["quantity"], extra=0, edit_only=True
)


def bulk_edit_quantities(request):
    own_lines = OrderLine.objects.filter(
        order__customer=request.user, order__status="draft"
    )
    if request.method == "POST":
        formset = LineQuantityFormSet(request.POST, queryset=own_lines)
        if formset.is_valid():
            formset.save()
            return redirect("cart")
    else:
        formset = LineQuantityFormSet(queryset=own_lines)
    return render(request, "orders/bulk_edit.html", {"formset": formset})

go deeper

for a junior

Remember that a model formset without queryset= covers the whole table, and that the queryset must be passed on both GET and POST.

for a middle

Explain how posted primary keys are matched against the formset's own queryset, and why extra, max_num and edit_only behave differently on a POST.

for a senior

Review bulk-edit views for the GET-only scoping bug, missing edit_only, unchecked counts and parents loaded without the user's scope, and add tests that post tampered ids.

for a principal

Set a team pattern so every bulk-edit page derives its queryset from one scoped helper, making the authorization rule impossible to apply on one branch only.

## The default queryset is every row `modelformset_factory(OrderLine, fields=["quantity"])` returns a formset class; instantiating it without `queryset=` makes it use `OrderLine._default_manager.get_queryset()` — **every row in the table**, with `pk` appended to its ordering so the rows come back in a stable order. `max_num` does not trim that list either: it never prevents existing objects from being displayed. For an "add only" page, pass `queryset=OrderLine.objects.none()`; for a bulk-edit page, pass a queryset filtered to what the user may touch. Calling `modelformset_factory` without `fields` or `exclude` (as arguments or on the form's `Meta`) raises `ImproperlyConfigured`; that whitelist decides which columns a POST may write, while the queryset decides which rows. ## How posted primary keys are matched A model formset renders each existing row's primary key as a hidden field (`form-0-id`). On a POST it rebuilds the forms like this: 1. It reads `TOTAL_FORMS` and `INITIAL_FORMS` from the management form. 2. For each of the first `INITIAL_FORMS` forms it reads the posted key and looks it up **in the formset's own queryset**. 3. If the key is found, that form edits that object. 4. If the key names a real row outside the queryset, it is not matched: the form gets a blank object, and `save()` skips it. A key that names no row at all fails the hidden field's validation. So the protection is the queryset passed **on the POST**. A view that scopes the GET but builds `LineQuantityFormSet(request.POST)` on the POST has handed every row back to the default manager: a user who edits the hidden `id` values can change other customers' lines. Scope both branches with the same queryset expression. ## Stopping rows the page never offered The other hole is creation. Every count the page shows is client input: - `extra=0` renders no blank rows, but a client can raise `TOTAL_FORMS` and post new ones, and `save()` creates them. - `max_num` only caps rendering unless `validate_max=True` is set. - **`edit_only=True`** (a `modelformset_factory` and `inlineformset_factory` argument since Django 4.1) makes `save()` save existing objects only; extra forms are still built and validated but never saved. - **`absolute_max`** (default `max_num + 1000`) caps how many forms Django instantiates from one POST; a larger `TOTAL_FORMS` makes the formset invalid, which blocks memory-exhaustion posts. | setting | stops new rows? | stops editing out-of-scope rows? | |---|---|---| | `extra=0` | no | no | | `max_num` alone | no | no | | `max_num` + `validate_max=True` | limits the count | no | | `edit_only=True` | yes | no | | scoped `queryset=` on POST | no | yes | ## Inline formsets scope themselves to a parent An inline formset filters its queryset by the parent `instance` and replaces the foreign key on each form with an `InlineForeignKeyField`, which rejects a posted value that does not match the parent. That keeps lines inside one order, but it trusts the order you pass in: the view must load it with the user's scope, for example `get_object_or_404(Order, pk=pk, customer=request.user)`. Checking who may edit which order is an authorization decision the formset does not make. ## Deletion follows the same rules A `DELETE` tick acts only on forms that matched an object in the formset's queryset, so a scoped queryset also scopes deletion. With `save(commit=False)` nothing is deleted until you delete `formset.deleted_objects` yourself. ## Testing the scoping A test that renders the page proves nothing about the POST. Useful cases for a bulk-edit formset: - post a line's real key with a changed quantity and assert it saved; - post another customer's line key in an initial form and assert that line is unchanged; - raise `TOTAL_FORMS` by one with a filled extra form and assert no row was created; - post a `TOTAL_FORMS` above `absolute_max` and assert the formset is invalid. Each case needs the management fields under the formset's real prefix, or it fails for the wrong reason. ## A review checklist 1. Is the same scoped `queryset=` passed on both GET and POST? 2. Is `fields=` an explicit list of what the page may change? 3. If the page must not create rows, is `edit_only=True` set? 4. If the count matters, is `validate_max=True` set with a deliberate `max_num`? 5. For inline formsets, is the parent loaded through the user's scope?

  • What happens to a posted primary key for a row outside the formset's queryset?
    The formset looks keys up only in its own queryset, so a key for a row outside it is not matched to any object; the form gets a blank instance and `save()` skips it. That is why the queryset, passed on the POST, is what scopes the page.
  • Why doesn't extra=0 make a model formset edit-only?
    `extra` only decides how many blank forms render. A client can raise `TOTAL_FORMS` and post additional forms, which the formset validates and `save()` creates. `edit_only=True`, available since Django 4.1, makes `save()` handle existing objects only.

saying these in an interview costs you the question

  • a model formset without queryset shows only the rows the page created
  • scoping the queryset on GET is enough because the ids come from the page
  • extra=0 prevents a model formset from creating new rows
  • max_num limits which existing rows a model formset displays
  • an inline formset also checks that the user may edit the parent