In the Django admin, what does the built-in 'Delete selected' action do, and how do you remove it from one model or the whole site?
answer
- ships with every admin site
- gated by the delete permission
- confirmation page lists cascades
- disable_action versus get_actions
basics
~20 sDjango's admin registers delete_selected on every AdminSite: it lists the ticked rows plus everything that would cascade with them, asks for confirmation, then logs and deletes them. Remove it site-wide with admin.site.disable_action(), or per model by overriding get_actions().
solid answer
~40 s`delete_selected` is the one action every `AdminSite` ships with, so each change list offers "Delete selected <objects>". It is declared with `@admin.action(permissions=["delete"])`, so staff only see it when `has_delete_permission(request)` passes. The first POST renders a confirmation page listing the chosen rows and every related object `get_deleted_objects()` finds would go with them; if one is protected or the user may not delete it, the page refuses. Confirming posts back with `post=yes`, and the action calls `log_deletions()`, then `delete_queryset()`, then shows a success message. To remove it everywhere, call `admin.site.disable_action("delete_selected")`; for one model, override `get_actions()` and pop the key, or set `actions = None` to switch off every action on that `ModelAdmin`.
code
python · 19 linesfrom django.contrib import admin
from django.contrib.admin import ActionLocation
from .models import Warehouse
# No change list on this site offers "Delete selected ..." any more.
admin.site.disable_action("delete_selected")
@admin.register(Warehouse)
class WarehouseAdmin(admin.ModelAdmin):
# Opt this model back in: the name still resolves through the site registry.
actions = ["delete_selected"]
def get_actions(self, request, action_location=ActionLocation.CHANGE_LIST):
actions = super().get_actions(request, action_location=action_location)
if not request.user.is_superuser:
actions.pop("delete_selected", None)
return actionsgo deeper
Recall that delete_selected is built in, needs the delete permission, and shows a confirmation page before anything is removed.
Explain the two POSTs, what get_deleted_objects collects, and the difference between disable_action, get_actions and actions = None.
Decide who should be able to bulk-delete production rows at all, and make sure custom deletion logic survives the bulk path via delete_queryset.
Weigh whether hard bulk deletes belong in an internal tool, or whether the admin should only archive or soft-delete records.
## What `delete_selected` is An **admin action** in `django.contrib.admin` is a function that runs over the rows a staff user ticks on a **change list** (the paginated table of records for one model). Django ships exactly one built-in action: **`delete_selected`**, defined in `django/contrib/admin/actions.py`. Every `AdminSite` registers it in its site-wide action registry when it is created, which is why every registered model shows **"Delete selected <verbose_name_plural>"** in the action dropdown without you writing anything. It is decorated like any custom action: - `permissions=["delete"]` — the action is only offered to users for whom `ModelAdmin.has_delete_permission(request)` returns `True`. - `description="Delete selected %(verbose_name_plural)s"` — the placeholder is filled from the model's `Meta.verbose_name_plural`. ## The two-step flow `delete_selected` is also the canonical example of an action with an **intermediate page**: 1. The user ticks rows, picks the action and clicks **Go**. The admin builds a queryset of the ticked primary keys and calls the action. 2. The action calls `ModelAdmin.get_deleted_objects()`, which runs Django's deletion **collector** and returns every object that would disappear by cascade, per-model counts, the permissions the user lacks, and any **protected** objects. 3. Because the POST has no `post` field yet, the action returns a `TemplateResponse` for `delete_selected_confirmation.html`. That page lists the whole cascade and carries hidden inputs: each selected primary key, `action=delete_selected` and `post=yes`. 4. On confirmation the same action runs again, sees `post`, calls `log_deletions()` (writing `LogEntry` history rows **before** the delete), then `delete_queryset(request, queryset)`, then `message_user()` with "Successfully deleted N …", and returns `None`, so the admin redirects back to the change list. If any collected object is protected by `on_delete=PROTECT` or `RESTRICT`, or belongs to a registered model the user may not delete, the page titles itself **"Cannot delete …"** and offers no confirm button. A hand-crafted confirming POST in that state still does not delete: protected objects skip the delete branch, and missing permissions raise `PermissionDenied`. ## Removing or restricting it | Scope | How | Effect | |---|---|---| | Whole site | `admin.site.disable_action("delete_selected")` | Gone from every change list on that site | | One model, back in after a global disable | `actions = ["delete_selected"]` on that `ModelAdmin` | The name still resolves through the site's registry | | One model, per request | Override `get_actions()` and pop `"delete_selected"` | E.g. only superusers keep it | | One model, all actions | `actions = None` | No dropdown and no checkbox column | | Via permissions | Deny `has_delete_permission()` | Also removes deletion from the change form | `disable_action()` only removes the action from the **enabled** set; the site keeps a second registry of every action ever added, which is why a `ModelAdmin` can name it again. ## What it does not do - It does **not** call each object's `Model.delete()`. The default `delete_queryset()` runs `queryset.delete()` for efficiency; what that skips is a QuerySet-API subject, and the admin-side fix is overriding `delete_queryset()`. - Its object-level check is limited to the collector pass: `has_delete_permission(request, obj)` is asked for every collected object of a registered model, the selected rows included, but which rows could be ticked at all is decided upstream by `get_queryset()`. - In Django 6.1 the new `delete_confirmation_max_display` option truncates long cascade lists on the confirmation page; the default `None` shows them all. ## Why interviewers ask it Juniors are expected to know the action exists, that it confirms first, and that it can be switched off. The follow-up — "why didn't my custom `delete()` run?" — separates people who have used the admin in production from those who have only registered models. ## Customising the confirmation page The page is an ordinary template, so it can be adapted without rewriting the action: - **`delete_selected_confirmation_template`** on a `ModelAdmin` names a template to use instead of the default. - Without it, the action looks up, in order, `admin/<app_label>/<model_name>/delete_selected_confirmation.html`, then `admin/<app_label>/delete_selected_confirmation.html`, then the built-in `admin/delete_selected_confirmation.html`, so a project can override one model or one app. - **`get_deleted_objects(objs, request)`** is a `ModelAdmin` hook; override it to change what the page lists or which permissions it demands. - **`delete_confirmation_max_display`** (Django 6.1) caps how many collected objects are listed before the rest is summarised, which matters when one customer row cascades into thousands of order lines. ## A quick way to check your understanding Ask yourself three things about any model in your admin: 1. Who can see "Delete selected" here — which `has_delete_permission()` answers `True`? 2. What else disappears with each row — which foreign keys cascade, and which are protected? 3. Does anything important live in `Model.delete()` that the bulk path would skip? If the third answer is yes, the fix is on the `ModelAdmin`, not in the model.
- Why can the Django admin's delete confirmation page say 'Cannot delete' when the user has delete permission on the model?The page lists the whole cascade. `get_deleted_objects()` checks `has_delete_permission(request, obj)` on every collected object whose model is registered in the admin — the selected rows and their cascaded children; any failure lands in `perms_lacking`. Objects blocked by `on_delete=PROTECT` or `RESTRICT` land in `protected`. Either one turns the page into "Cannot delete", so the user needs rights on the children too, or the relation must change.
- In the Django admin, what is the difference between setting actions = None and actions = [] on a ModelAdmin?`actions = None` makes `get_actions()` return an empty dict, so the change list shows no dropdown and no checkbox column at all. `actions = []` only removes the model's own actions; site-wide ones such as `delete_selected` are still gathered from the `AdminSite` and offered.
saying these in an interview costs you the question
- delete_selected deletes immediately without a confirmation step
- Every staff user sees delete_selected regardless of permissions
- Setting actions = [] on a ModelAdmin removes delete_selected
- disable_action deletes the action so no model can re-enable it
- delete_selected calls each object's delete() method