Staff bulk-deleted orders via the Django admin's 'Delete selected' action and your Order.delete() cleanup never ran; how do you fix it in the admin?
answer
- bulk path versus single-object path
- the hook the action calls
- delete_model is the other half
- history is logged before deleting
basics
~10 sdelete_selected calls ModelAdmin.delete_queryset(), whose default runs queryset.delete() and never calls Order.delete(). Override delete_queryset() to delete per object in a transaction, and keep delete_model() consistent for the single-object delete view.
solid answer
~40 sThe admin has two deletion hooks. The change form's delete view calls `delete_model(request, obj)`, which runs `obj.delete()`, so your override fires there. The `delete_selected` action instead calls `delete_queryset(request, queryset)`, whose default is `queryset.delete()` — one bulk operation that does not call each instance's `delete()`. The fix on the admin side is to override `delete_queryset()` and loop, calling `obj.delete()` inside `transaction.atomic()`, or better, route both hooks through one service function that owns the cleanup. `log_deletions()` has already written the `LogEntry` history before the hook runs, and the confirmation page already listed the cascade. Per-object deletes cost a query set per row, so for very large selections consider moving the cleanup to a database-level or signal-based mechanism, or doing it in batches.
code
python · 20 linesfrom django.contrib import admin
from django.db import transaction
from .models import Order
from .services import release_stock
@admin.register(Order)
class OrderAdmin(admin.ModelAdmin):
def delete_model(self, request, obj):
with transaction.atomic():
release_stock(obj, by=request.user)
obj.delete()
def delete_queryset(self, request, queryset):
# Called by the delete_selected action; the default is queryset.delete().
with transaction.atomic():
for obj in queryset.iterator(chunk_size=500):
release_stock(obj, by=request.user)
obj.delete()go deeper
Recall that bulk deletion in the admin goes through a different hook from deleting one object on its change form.
Explain delete_model versus delete_queryset, their defaults, and the order of confirmation, logging and deletion in delete_selected.
Put deletion side effects in one service both hooks call, make the loop transactional and memory-bounded, and weigh signals or database rules for other bulk paths.
Decide where invariants that must survive every deletion path live, so the admin is one caller of the rule rather than its only enforcer.
## The symptom An `Order` model overrides `delete()` to release reserved stock and write an audit record. Deleting one order from its admin change form works. A staff member ticks 200 orders, runs **"Delete selected orders"**, and the stock is never released. ## Why: two deletion paths in the admin The Django admin deletes through two different `ModelAdmin` hooks: | Path | Entry point | Hook it calls | Default body | |---|---|---|---| | One object | the change form's **Delete** button → `delete_view()` | `delete_model(request, obj)` | `obj.delete()` | | Many objects | the **`delete_selected`** action | `delete_queryset(request, queryset)` | `queryset.delete()` | The bulk default calls `QuerySet.delete()` "for efficiency reasons", and the documented caveat is that your model's `delete()` method is not called. What `QuerySet.delete()` does and does not trigger is the QuerySet API's subject; the admin-side lever is the hook. ## The order of events in `delete_selected` 1. First POST: `get_deleted_objects()` collects the cascade and the confirmation page is shown. 2. Confirming POST: if nothing is protected and no permission is missing, the action evaluates the selection to count it. 3. `log_deletions(request, queryset)` writes `LogEntry` rows **before** deletion (Django 5.1 replaced the per-object `log_deletion()` with this queryset-level, overridable hook). 4. `delete_queryset(request, queryset)` — your override point. 5. A success message; the admin redirects to the change list. ## The fix - **Override `delete_queryset()`** to iterate the queryset and call `obj.delete()` on each, inside `transaction.atomic()` so a failure halfway leaves nothing half-deleted. - **Keep `delete_model()` consistent.** Both hooks should reach the same code; the cleanest shape is a service function such as `cancel_and_delete(orders, by=user)` that both call, so the admin, an API and a management command share one rule. - **Use `iterator()` for large selections** so thousands of instances are not held in memory at once, and consider batching the transaction. ## Choosing where cleanup belongs 1. **In the admin hook** — fixes the admin only; other bulk paths such as scripts calling `queryset.delete()` still skip it. 2. **In a service function** — explicit, testable, shared by every caller; the admin hooks become thin. 3. **In `pre_delete`/`post_delete` receivers** — they are sent for each object even by `QuerySet.delete()`, so they cover bulk paths too, at the cost of implicit behaviour; that trade-off belongs to the signals topic. 4. **In the database** — constraints or cascades for pure data rules, which need no Python at all. ## Pitfalls seen in production - Overriding only `delete_model()` and believing the admin is covered. - Looping `obj.delete()` without a transaction, so an exception at row 150 leaves 149 deleted and 51 not. - Assuming the confirmation page's cascade list reflects your custom cleanup — it only reflects foreign-key cascades the collector finds. - Forgetting `select_across`: "Select all" can hand `delete_queryset()` every filtered row, not one page. ## Why the default is bulk Deleting 200 orders one by one means 200 separate collector passes and 200 rounds of `DELETE` statements; `QuerySet.delete()` collects the whole selection once and deletes in batches per table. For most models, with no custom `delete()`, the bulk path is simply faster and produces the same result. Django chose speed for the common case and documented the hook for the rest. ## Testing the fix 1. Create two orders with reserved stock. 2. POST to the change list with `action=delete_selected`, both keys as `_selected_action` and `post=yes`, logged in as a user with delete permission. 3. Assert both orders are gone, the stock was released, and two `LogEntry` rows with the deletion flag exist. 4. Make `release_stock()` raise for the second order, expect the exception (the test client re-raises it), and assert that neither order was deleted — the transaction's job. The same test run against the single-object delete view confirms `delete_model()` and `delete_queryset()` agree.
- When does the Django admin write the LogEntry history for a bulk delete, and why does the timing matter?`delete_selected` calls `log_deletions()` before `delete_queryset()`, because once rows are gone their primary keys and string representations cannot be read. If your override later fails and rolls back, the log entries may already exist unless the whole action runs in one transaction, so history can claim deletions that did not happen.
- Why might overriding delete_queryset() to loop obj.delete() be the wrong fix for a 50,000-row selection?Each `obj.delete()` runs its own collector queries and deletes, so tens of thousands of rows mean hundreds of thousands of queries inside one long transaction holding locks. For that volume, move the cleanup to set-based SQL or a database rule, batch the work, or hand it to a background job and have the admin only enqueue it.
saying these in an interview costs you the question
- Overriding delete_model covers the Delete selected action too
- delete_selected calls each object's delete() method
- The admin runs delete_queryset inside one transaction for you
- The confirmation page shows everything custom delete() would clean up