Skip to main content
List actions appear at the top of your changelist view and allow you to perform operations that affect multiple objects or the entire list. They’re ideal for bulk operations, exports, or navigating to related views.

Configuration

List actions are configured using the actions_list attribute in your ModelAdmin class:
admin.py
from django.contrib import admin
from unfold.admin import ModelAdmin
from unfold.decorators import action
from .models import Article

@admin.register(Article)
class ArticleAdmin(ModelAdmin):
    actions_list = [
        "export_articles",
        "generate_report",
        "bulk_publish",
    ]

Basic List Action

A list action receives the request object as its only parameter:
from django.contrib import messages
from django.shortcuts import redirect
from django.urls import reverse_lazy
from unfold.decorators import action

@action(description="Export All Articles")
def export_articles(self, request):
    # Access queryset through model
    articles = self.model.objects.all()
    
    # Perform export logic
    # ...
    
    messages.success(request, f"Exported {articles.count()} articles")
    return redirect(reverse_lazy("admin:myapp_article_changelist"))
List actions should return an HttpResponse, typically a redirect back to the changelist or to a download view.

Styled Actions with Icons

Enhance your actions with visual styling using icons and variants:
from unfold.decorators import action
from unfold.enums import ActionVariant

@action(
    description="Publish All",
    icon="upload",
    variant=ActionVariant.SUCCESS
)
def bulk_publish(self, request):
    count = self.model.objects.filter(status="draft").update(status="published")
    messages.success(request, f"Published {count} articles")
    return redirect(reverse_lazy("admin:myapp_article_changelist"))

@action(
    description="Delete Drafts",
    icon="trash",
    variant=ActionVariant.DANGER
)
def delete_drafts(self, request):
    count, _ = self.model.objects.filter(status="draft").delete()
    messages.warning(request, f"Deleted {count} draft articles")
    return redirect(reverse_lazy("admin:myapp_article_changelist"))
Organize multiple related actions into dropdown menus:
@admin.register(Article)
class ArticleAdmin(ModelAdmin):
    actions_list = [
        "quick_publish",
        {
            "title": "Export Options",
            "icon": "download",
            "variant": ActionVariant.PRIMARY,
            "items": [
                "export_csv",
                "export_json",
                "export_pdf",
            ],
        },
        {
            "title": "Bulk Operations",
            "icon": "gear",
            "items": [
                "bulk_archive",
                "bulk_delete",
                "bulk_restore",
            ],
        },
    ]

    @action(description="Quick Publish All")
    def quick_publish(self, request):
        pass

    @action(description="CSV Export", icon="file-csv")
    def export_csv(self, request):
        pass

    @action(description="JSON Export", icon="file-code")
    def export_json(self, request):
        pass

    @action(description="PDF Export", icon="file-pdf")
    def export_pdf(self, request):
        pass
Dropdown items can have their own icons and variants, which will be displayed in the dropdown menu.

Permission-Protected Actions

Restrict actions to users with specific permissions:
@action(
    description="Bulk Delete",
    permissions=["myapp.delete_article"],
    icon="trash",
    variant=ActionVariant.DANGER
)
def bulk_delete(self, request):
    # Only users with delete_article permission can execute this
    pass

@action(
    description="Publish to Production",
    permissions=["publish_production"],
    icon="rocket",
    variant=ActionVariant.WARNING
)
def publish_production(self, request):
    # Uses custom permission method
    pass

def has_publish_production_permission(self, request):
    # Custom permission logic
    return request.user.is_superuser or request.user.groups.filter(name="Publishers").exists()

File Export Example

Create a complete CSV export action:
import csv
from django.http import HttpResponse
from django.utils import timezone
from unfold.decorators import action

@action(
    description="Export to CSV",
    icon="download",
    variant=ActionVariant.INFO
)
def export_csv(self, request):
    # Create response with CSV content type
    response = HttpResponse(content_type="text/csv")
    timestamp = timezone.now().strftime("%Y%m%d_%H%M%S")
    response["Content-Disposition"] = f'attachment; filename="articles_{timestamp}.csv"'
    
    # Create CSV writer
    writer = csv.writer(response)
    writer.writerow(["ID", "Title", "Author", "Status", "Created"])
    
    # Write data
    for article in self.model.objects.all():
        writer.writerow([
            article.id,
            article.title,
            article.author.username,
            article.status,
            article.created_at.strftime("%Y-%m-%d %H:%M:%S"),
        ])
    
    return response
Open external URLs with custom attributes:
@action(
    description="View Analytics",
    icon="chart-line",
    variant=ActionVariant.INFO,
    attrs={"target": "_blank", "rel": "noopener noreferrer"},
    url_path="analytics-dashboard"
)
def view_analytics(self, request):
    # Redirect to external analytics dashboard
    analytics_url = f"https://analytics.example.com/dashboard?app={self.model._meta.app_label}"
    return redirect(analytics_url)
When using attrs with target="_blank", always include rel="noopener noreferrer" for security.

Working with Querysets

Access the current queryset with filters applied:
from django.contrib import messages
from django.shortcuts import redirect
from django.urls import reverse_lazy
from unfold.decorators import action

@action(
    description="Process Filtered Items",
    icon="cog",
    variant=ActionVariant.PRIMARY
)
def process_filtered(self, request):
    # Get the changelist to access filtered queryset
    from unfold.views import ChangeList
    
    cl = ChangeList(
        request, self.model, self.list_display,
        self.list_display_links, self.list_filter,
        self.date_hierarchy, self.search_fields,
        self.list_select_related, self.list_per_page,
        self.list_max_show_all, self.list_editable, self, self.sortable_by,
    )
    
    # Process only filtered items
    queryset = cl.get_queryset(request)
    count = queryset.count()
    
    # Process items...
    
    messages.success(request, f"Processed {count} filtered items")
    return redirect(reverse_lazy("admin:myapp_article_changelist"))

Best Practices

List actions should always return an HttpResponse, typically a redirect to avoid confusion:
return redirect(reverse_lazy("admin:myapp_article_changelist"))
Use Django’s messages framework to inform users of action results:
messages.success(request, "Operation completed successfully")
messages.warning(request, "Some items were skipped")
messages.error(request, "Operation failed")
Choose variants that match the action’s nature:
  • SUCCESS for positive actions (publish, approve)
  • DANGER for destructive actions (delete, archive)
  • WARNING for risky actions (deploy, sync)
  • INFO for informational actions (export, view)
  • PRIMARY for main actions
  • DEFAULT for standard actions

Hiding Default Actions

If you want to completely replace Django’s default bulk actions:
@admin.register(Article)
class ArticleAdmin(ModelAdmin):
    actions_list_hide_default = True
    actions_list = [
        "custom_bulk_action",
    ]
When hiding default actions, ensure your custom actions provide all necessary functionality.

Row Actions

Add actions to individual rows in your list view

Detail Actions

Create actions for object detail views

Build docs developers (and LLMs) love