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?
answer
- what the default queryset contains
- posted primary keys are client input
- scope on POST, not only GET
- extra=0 is only a display setting
- an argument added in 4.1
basics
~20 sPass 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 sA 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 linesfrom 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
Remember that a model formset without queryset= covers the whole table, and that the queryset must be passed on both GET and POST.
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.
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.
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