skip to content

In Django's admin, a CourseAdmin.save_model() recomputes total lesson minutes but the total is always one save behind; why, and where does that code belong?

level: seniorimportance: should knowfreq 40%

answer

  1. order of the hooks on POST
  2. parent first, children later
  3. many-to-many is saved late too
  4. call super, then recompute
  5. one transaction around it all

basics

~10 s

save_model() runs before the inline lessons are saved; save_related() saves many-to-many data and inline formsets afterwards. Recompute totals in save_related() after calling super(), when the lessons in the database match the form.

solid answer

~40 s

On a valid POST the admin runs, inside one `transaction.atomic` block: `save_form()` (the parent form's `save(commit=False)`), then `save_model(request, obj, form, change)`, which by default calls `obj.save()`, then `save_related(request, form, formsets, change)`, which calls `form.save_m2m()` and `save_formset()` for each inline, then logs the change. So when `save_model()` sums `obj.lessons`, it reads the lessons as they were **before** this submission; the edits land a moment later. Move the recomputation to `save_related()`, after `super().save_related(...)`, and save the course again. Keep `save_model()` for parent-only work such as `if not change: obj.created_by = request.user` before `super()`. Neither hook is for vetoing a save: validation belongs in the form.

code

python · 25 lines
python
from django.contrib import admin
from django.db.models import Sum

from courses.models import Course, Lesson


class LessonInline(admin.TabularInline):
    model = Lesson


@admin.register(Course)
class CourseAdmin(admin.ModelAdmin):
    inlines = [LessonInline]
    readonly_fields = ["created_by", "total_minutes"]

    def save_model(self, request, obj, form, change):
        if not change:
            obj.created_by = request.user
        super().save_model(request, obj, form, change)

    def save_related(self, request, form, formsets, change):
        super().save_related(request, form, formsets, change)  # m2m + lessons saved
        course = form.instance
        course.total_minutes = course.lessons.aggregate(t=Sum("duration_minutes"))["t"] or 0
        course.save(update_fields=["total_minutes"])

go deeper

for a junior

Remember that save_model() is where you set fields such as created_by from request.user, and that you must call super().

for a middle

Explain the full order: save_form, save_model, save_related with save_m2m and save_formset, all inside one transaction.

for a senior

Show you place derived data after children are saved, keep veto logic in forms, and defer side effects until the transaction commits.

for a principal

Decide which business rules may live in admin hooks and which must sit in shared domain code used by every write path.

## The order of events on a save When an editor clicks **Save** on a change form, `ModelAdmin.changeform_view()` wraps the whole POST in `transaction.atomic` on the model's write database. Inside it: 1. The parent `ModelForm` is validated, and `save_form(request, form, change)` returns `form.save(commit=False)`: an unsaved (or unsaved-changes) instance. 2. Inline formsets are built and validated; the save proceeds only if the form and **all** formsets are valid. 3. `save_model(request, obj, form, change)` runs. Its default body is `obj.save()`. 4. `save_related(request, form, formsets, change)` runs. Its default calls `form.save_m2m()` and then `save_formset(request, form, formset, change)` for each inline, which calls `formset.save()`. 5. The change is logged (`log_addition()` or `log_change()`) and the response redirects. `change` is `False` when adding and `True` when editing. ## Why the total is one save behind At step 3 the lessons in the database are still the old ones: new lessons are not inserted, edited durations are not updated and deleted lessons are still there. A `save_model()` that sums `obj.lessons.aggregate(Sum("duration_minutes"))` reads that old state. The inline changes arrive at step 4, after the total has been stored. The next save computes from the previous edit, hence "one save behind". The same applies to many-to-many fields on the parent itself: with `commit=False`, `form.save_m2m()` is deferred to `save_related()`, so `obj.tags.all()` inside `save_model()` shows the old tags. ## A trace of one save The course currently has lessons of 30 and 20 minutes, so `total_minutes` is 50. The editor changes the 20 to 25 and adds a 15-minute lesson, then saves: 1. `save_model()` sums the lessons in the database: still 30 + 20 = 50, and stores 50. 2. `save_related()` updates the second lesson to 25 and inserts the new one. 3. The page reloads showing 50, while the lessons add up to 70. 4. On the next save, with no changes, `save_model()` finally computes 70. Moving the computation after `super().save_related()` makes step 1 irrelevant and the stored total is 70 immediately. ## Where each kind of code belongs | Hook | Runs | Put here | |---|---|---| | `save_model()` | before children and m2m | fields of the parent that come from the request: `created_by`, `updated_by` | | `save_formset()` | once per inline, inside `save_related()` | per-child tweaks, e.g. stamping each new lesson with `request.user` | | `save_related()` | after m2m and all inlines | anything derived from children: totals, counts, reindexing positions | | the form's `clean()` | before any save | rules that should **reject** a save | A `save_formset()` override that changes instances follows the documented pattern: `formset.save(commit=False)`, delete `formset.deleted_objects`, save each instance, then `formset.save_m2m()`. ## The fix - Override `save_related()`, call `super().save_related(request, form, formsets, change)` first, then recompute from the database and save only the derived field with `update_fields`. - Keep the attribution in `save_model()` and call `super().save_model(...)`, because the default is what actually saves; an override that forgets it silently drops the edit. ## Things interviewers probe - **Transactions.** Everything above shares one atomic block, so an exception in `save_related()` rolls back the parent save too. That is usually what you want; a side effect such as sending an email should be deferred until commit rather than fired mid-transaction. - **Not for veto.** The docs are explicit that `save_model()` and `delete_model()` must save or delete; they are for extra operations, not for refusing. Raising there produces an error page, not a form error. Validation belongs in the form or in model validation. - **Only the admin.** These hooks run for admin saves only. If the total must be right for API writes too, the recomputation belongs in a service function or model method that both the admin hook and other code call.

  • In Django's admin, what happens if save_model() is overridden without calling super().save_model()?
    The default implementation is what calls `obj.save()`. Without it the parent is not saved, yet `save_related()` still runs. For an existing course the edit is silently lost while the admin reports success; for a new one, saving inlines or m2m data against an unsaved parent typically raises an error. Always call `super()` or save explicitly.
  • Should a Django admin reject an invalid course by raising an exception in save_model()?
    No. By then the form is already valid and the admin expects the hook to save. Raising produces a server error inside the transaction, not a message on the form. Put the rule in the admin form's `clean()` or the model's validation so the editor sees a field error and nothing is saved.

saying these in an interview costs you the question

  • save_model() runs after the inline formsets have been saved
  • Inside save_model(), obj.tags.all() already reflects the submitted tags
  • Each admin hook commits separately, so a failure in save_related() keeps the parent
  • save_model() is the right place to refuse invalid saves
  • Overriding save_model() without super() still saves the object