Custom Admin Actions in Django (List-Page Bulk Actions)

Blog / Django · April 3, 2021 · Updated June 10, 2026 · 7 min read
Custom Admin Actions in Django (List-Page Bulk Actions)

Admin actions are the bulk operations that appear in the dropdown above a model's change-list (the table view) in the Django admin. You tick the rows you want, pick an action, hit Go, and Django runs your function against the selected queryset. Out of the box every model ships with one action: Delete selected.

This guide covers writing custom actions the modern Django 5.x way with the @admin.action decorator, giving users feedback, gating actions behind permissions, adding a confirmation/intermediate page, exporting selected rows to CSV, and registering or disabling actions site-wide. All code is Python 3 / Django 5.x.

How admin actions work

An action is just a callable that receives three arguments and operates on the rows the user selected:

  • modeladmin (or self when it's a method) — the ModelAdmin instance.
  • request — the current HttpRequest.
  • queryset — a QuerySet of the selected objects.

If the callable returns None, the user is sent back to the change-list page. If it returns an HttpResponse, that response is shown instead — which is how confirmation/intermediate pages work (more on that below).

We'll use this small BlogPost model throughout. In models.py:

from django.db import models


class BlogPost(models.Model):
    STATUS_CHOICES = [
        ("d", "Draft"),
        ("p", "Published"),
        ("r", "Review"),
        ("t", "Trash"),
    ]

    title = models.CharField(max_length=100)
    content = models.TextField()
    status = models.CharField(max_length=1, choices=STATUS_CHOICES, default="d")
    created_date = models.DateTimeField(auto_now_add=True)

    def __str__(self):
        return self.title

Writing a custom action (the modern way)

Since Django 3.2 the clean way to declare an action is the @admin.action decorator. It sets the dropdown label (description) and the permission gate (permissions) for you. Define the action as a method on your ModelAdmin and list it in actions. In admin.py:

from django.contrib import admin, messages

from .models import BlogPost


@admin.register(BlogPost)
class BlogPostAdmin(admin.ModelAdmin):
    list_display = ("title", "status", "created_date")
    list_filter = ("status",)
    actions = ["make_published", "make_draft"]

    @admin.action(description="Mark selected posts as published", permissions=["change"])
    def make_published(self, request, queryset):
        updated = queryset.update(status="p")
        self.message_user(
            request,
            f"{updated} post(s) marked as published.",
            messages.SUCCESS,
        )

    @admin.action(description="Move selected posts to draft", permissions=["change"])
    def make_draft(self, request, queryset):
        updated = queryset.update(status="d")
        self.message_user(request, f"{updated} post(s) moved to draft.", messages.WARNING)

The actions list names the methods to expose; the dropdown label comes from description. queryset.update(...) issues a single SQL UPDATE for all selected rows — fast, but note the gotcha at the end of this post.

For reference, the pre-3.2 way set those attributes on the function by hand. You'll still see this in older code:

# Legacy style (still works, but prefer @admin.action today)
def make_published(modeladmin, request, queryset):
    queryset.update(status="p")


make_published.short_description = "Mark selected posts as published"
make_published.allowed_permissions = ("change",)

Giving the user feedback

Always tell the user what happened. ModelAdmin.message_user() adds a message to Django's messages framework, shown as a banner on the next page:

  • messages.SUCCESS — green confirmation ("12 posts published").
  • messages.WARNING / messages.ERROR — for partial or failed runs.
  • messages.INFO — neutral notices.

Reporting the affected count (the return value of queryset.update(), or queryset.count()) makes the feedback genuinely useful.

Restricting who can run an action

The permissions kwarg controls which users see and run an action. Each name maps to a has_<name>_permission method on the ModelAdmin:

  • permissions=["change"] → user needs has_change_permission.
  • permissions=["delete"], ["add"], ["view"] → the corresponding built-in checks.

You can also gate an action behind a custom permission by defining the matching method. For example, only let users with app.publish_blogpost run a publish action:

from django.contrib import admin


@admin.register(BlogPost)
class BlogPostAdmin(admin.ModelAdmin):
    actions = ["make_published"]

    @admin.action(description="Mark selected posts as published", permissions=["publish"])
    def make_published(self, request, queryset):
        queryset.update(status="p")

    def has_publish_permission(self, request):
        """Gate the action behind a custom 'publish_blogpost' permission."""
        return request.user.has_perm("blog.publish_blogpost")

Adding a confirmation / intermediate page

Sometimes an action needs extra input ("which status?") or an explicit confirmation before it runs. The pattern: when the form hasn't been submitted yet, return a rendered page; when it has, do the work and return None to fall back to the change list.

The page must re-send the selected primary keys so the admin can rebuild the queryset. Those checkboxes use the name in admin.contrib.admin.helpers.ACTION_CHECKBOX_NAME (the string "_selected_action").

from django import forms
from django.contrib import admin, messages
from django.contrib.admin.helpers import ACTION_CHECKBOX_NAME
from django.shortcuts import render

from .models import BlogPost


class StatusForm(forms.Form):
    status = forms.ChoiceField(choices=BlogPost.STATUS_CHOICES, label="New status")


@admin.action(description="Change status of selected posts…", permissions=["change"])
def change_status(modeladmin, request, queryset):
    # Second pass: the user submitted the intermediate form.
    if "apply" in request.POST:
        form = StatusForm(request.POST)
        if form.is_valid():
            updated = queryset.update(status=form.cleaned_data["status"])
            modeladmin.message_user(
                request, f"Updated status for {updated} post(s).", messages.SUCCESS
            )
            return None  # back to the change-list page

    # First pass: show the intermediate page.
    return render(
        request,
        "admin/change_status.html",
        {
            "title": "Change status",
            "posts": queryset,
            "form": StatusForm(),
            "action_checkbox_name": ACTION_CHECKBOX_NAME,
        },
    )

The template re-posts each selected pk as a hidden field, plus action (so Django knows which action to call again) and apply (so the action knows it's the second pass). In templates/admin/change_status.html:

{% extends "admin/base_site.html" %}

{% block content %}
<form action="" method="post">{% csrf_token %}
  <p>Set a new status for the {{ posts.count }} selected post(s):</p>
  {{ form.as_p }}

  {% for post in posts %}
    <input type="hidden" name="{{ action_checkbox_name }}" value="{{ post.pk }}">
  {% endfor %}
  <input type="hidden" name="action" value="change_status">

  <input type="submit" name="apply" value="Apply">
  <a href="#" onclick="window.history.back(); return false;">Cancel</a>
</form>
{% endblock %}

Exporting selected rows to CSV

A CSV export is the textbook "return an HttpResponse" action — instead of redirecting, you stream a file back to the browser:

import csv

from django.contrib import admin
from django.http import HttpResponse


@admin.action(description="Export selected posts to CSV", permissions=["view"])
def export_as_csv(modeladmin, request, queryset):
    field_names = ["id", "title", "status", "created_date"]

    response = HttpResponse(content_type="text/csv")
    response["Content-Disposition"] = "attachment; filename=blogposts.csv"

    writer = csv.writer(response)
    writer.writerow(field_names)
    for obj in queryset:
        writer.writerow([getattr(obj, field) for field in field_names])

    return response

Add export_as_csv to a model's actions list to expose it there, or register it for every model — see the next section.

Site-wide actions and disabling actions

Register an action on every model admin with admin.site.add_action(), and remove a built-in action everywhere with admin.site.disable_action():

from django.contrib import admin


# Make the CSV export available on every model's change list.
admin.site.add_action(export_as_csv, "export_as_csv")

# Remove the built-in "Delete selected" action across the whole admin site.
admin.site.disable_action("delete_selected")

To drop a single action for one model only, override get_actions() and pop it:

@admin.register(BlogPost)
class BlogPostAdmin(admin.ModelAdmin):
    actions = ["make_published"]

    def get_actions(self, request):
        actions = super().get_actions(request)
        actions.pop("delete_selected", None)  # remove just for this model
        return actions

To remove the actions dropdown entirely for a model, set actions = None:

@admin.register(BlogPost)
class BlogPostAdmin(admin.ModelAdmin):
    actions = None  # no actions checkbox column or dropdown at all

Which approach should you use?

Approach Use it when Trade-offs
queryset.update(...) Simple bulk field change One fast SQL query; skips save(), signals and auto_now
Loop + obj.save() You need signals or model logic Fires signals and per-object logic; N queries, slower
Intermediate page You need extra input or a confirm step Two-step UX; the action returns an HttpResponse

Gotcha: queryset.update() skips save() and signals

Actions run against a database queryset, and queryset.update() compiles to a single SQL UPDATE. That means it does not call each model's save() method, does not fire pre_save/post_save signals, and does not refresh auto_now fields. If your publish step needs to send notifications, recompute a field, or trigger signal-based side effects, loop and call save() instead:

from django.contrib import admin, messages


@admin.action(description="Publish & run model logic", permissions=["change"])
def publish_with_signals(modeladmin, request, queryset):
    count = 0
    for post in queryset:
        post.status = "p"
        post.save()  # fires pre_save/post_save signals, runs custom save() logic
        count += 1
    modeladmin.message_user(request, f"Published {count} post(s).", messages.SUCCESS)

Frequently Asked Questions

How do I add a custom admin action in Django?

Write a function (or a ModelAdmin method) that takes (self, request, queryset), decorate it with @admin.action(description="…"), and list its name in your ModelAdmin.actions. Inside, operate on the queryset — for example queryset.update(status="p") — and call self.message_user() to confirm the result.

How do I restrict who can run an admin action?

Pass permissions=["change"] (or "add", "delete", "view") to @admin.action. Each name maps to the matching has_<name>_permission method, so only users with that permission see and run the action. For a custom rule, use a custom name like permissions=["publish"] and define has_publish_permission(self, request) returning a boolean.

How do I add a confirmation page to an admin action?

Return a rendered HttpResponse from the action on the first pass, and do the work on the second. Render a small form template that re-posts each selected primary key as a hidden field named admin.helpers.ACTION_CHECKBOX_NAME ("_selected_action"), plus hidden action and an apply submit button. When "apply" is in request.POST, validate the form, update the queryset, and return None to go back to the change list.

How do I export selected rows to CSV from the admin?

Create an action that builds an HttpResponse(content_type="text/csv"), sets a Content-Disposition: attachment header, writes a header row with csv.writer, then iterates the queryset writing each object's fields. Return the response instead of None so the browser downloads the file.

Why doesn't my action trigger save() or signals?

Because queryset.update() is a single SQL UPDATE that bypasses the model layer — it never calls save(), never fires pre_save/post_save signals, and won't touch auto_now fields. When you need that behaviour, iterate the queryset and call obj.save() on each object instead.

How do I remove the default "Delete selected" action?

Globally, call admin.site.disable_action("delete_selected"). For a single model, override get_actions() and actions.pop("delete_selected", None). To remove the actions dropdown entirely for a model, set actions = None on its ModelAdmin.

Related reading

We've built and maintained Django applications for 12+ years across 50+ projects. If you'd like a hand designing admin workflows, see our Django development services.

Share this article