Skip to main content
Row actions appear as buttons or links in each row of your changelist table, providing quick access to operations on individual objects without navigating to the detail view. They’re perfect for quick edits, status changes, or navigation shortcuts.

Configuration

Row actions are configured using the actions_row 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_row = [
        "quick_view",
        "toggle_published",
        "duplicate",
    ]

Basic Row Action

A row action receives both request and object_id parameters:
from django.contrib import messages
from django.shortcuts import redirect
from django.urls import reverse_lazy
from unfold.decorators import action

@action(description="Quick View")
def quick_view(self, request, object_id):
    # Get the object
    article = self.get_object(request, object_id)
    
    # Perform action
    messages.info(request, f"Viewing: {article.title}")
    return redirect(article.get_absolute_url())
Row actions share the same signature as detail actions (request, object_id) but are displayed in the list view table.

Styled Row Actions

Enhance row actions with icons and color variants:
from unfold.decorators import action
from unfold.enums import ActionVariant

@action(
    description="View",
    icon="eye",
    variant=ActionVariant.INFO
)
def quick_view(self, request, object_id):
    article = self.get_object(request, object_id)
    return redirect(article.get_absolute_url())

@action(
    description="Edit",
    icon="pencil",
    variant=ActionVariant.PRIMARY
)
def quick_edit(self, request, object_id):
    return redirect(reverse_lazy("admin:myapp_article_change", args=[object_id]))

@action(
    description="Delete",
    icon="trash",
    variant=ActionVariant.DANGER
)
def quick_delete(self, request, object_id):
    article = self.get_object(request, object_id)
    title = article.title
    article.delete()
    
    messages.warning(request, f"Deleted: {title}")
    return redirect(reverse_lazy("admin:myapp_article_changelist"))

Toggle Actions Example

Create actions that toggle object states:
from django.utils import timezone
from unfold.decorators import action
from unfold.enums import ActionVariant

@action(
    description="Toggle Published",
    icon="power-off",
    variant=ActionVariant.WARNING
)
def toggle_published(self, request, object_id):
    article = self.get_object(request, object_id)
    
    if article.is_published:
        article.is_published = False
        article.unpublished_at = timezone.now()
        status = "unpublished"
    else:
        article.is_published = True
        article.published_at = timezone.now()
        status = "published"
    
    article.save()
    messages.success(request, f"Article {status}: {article.title}")
    return redirect(reverse_lazy("admin:myapp_article_changelist"))

@action(
    description="Toggle Featured",
    icon="star",
    variant=ActionVariant.SUCCESS
)
def toggle_featured(self, request, object_id):
    article = self.get_object(request, object_id)
    article.is_featured = not article.is_featured
    article.save()
    
    status = "featured" if article.is_featured else "unfeatured"
    messages.success(request, f"Article {status}")
    return redirect(reverse_lazy("admin:myapp_article_changelist"))

@action(
    description="Archive",
    icon="box-archive",
    variant=ActionVariant.WARNING
)
def archive_article(self, request, object_id):
    article = self.get_object(request, object_id)
    article.archived = True
    article.archived_at = timezone.now()
    article.archived_by = request.user
    article.save()
    
    messages.info(request, f"Archived: {article.title}")
    return redirect(reverse_lazy("admin:myapp_article_changelist"))

Permission-Protected Row Actions

Control access to row actions based on permissions:
@action(
    description="Publish",
    permissions=["myapp.publish_article"],
    icon="check",
    variant=ActionVariant.SUCCESS
)
def publish_article(self, request, object_id):
    article = self.get_object(request, object_id)
    article.status = "published"
    article.published_by = request.user
    article.save()
    
    messages.success(request, "Article published")
    return redirect(reverse_lazy("admin:myapp_article_changelist"))

@action(
    description="Approve",
    permissions=["approve"],
    icon="thumbs-up",
    variant=ActionVariant.PRIMARY
)
def approve_article(self, request, object_id):
    article = self.get_object(request, object_id)
    article.approved = True
    article.approved_by = request.user
    article.save()
    return redirect(reverse_lazy("admin:myapp_article_changelist"))

def has_approve_permission(self, request, object_id=None):
    # Note: object_id is optional for row actions in permission methods
    if not request.user.groups.filter(name="Editors").exists():
        return False
    
    if object_id:
        article = self.get_object(request, object_id)
        # Can't approve own articles
        return article.author != request.user
    
    return True
Row action permission methods can optionally use object_id for object-level permission checks. The button will be hidden for rows where permission is denied.

Duplicate Action Example

Create a complete duplication action:
from django.utils.text import slugify
from unfold.decorators import action
from unfold.enums import ActionVariant

@action(
    description="Duplicate",
    icon="copy",
    variant=ActionVariant.INFO
)
def duplicate_article(self, request, object_id):
    original = self.get_object(request, object_id)
    
    # Create a copy
    duplicate = self.model.objects.create(
        title=f"{original.title} (Copy)",
        slug=f"{original.slug}-copy",
        content=original.content,
        author=request.user,
        status="draft",
    )
    
    # Copy many-to-many relationships
    duplicate.tags.set(original.tags.all())
    duplicate.categories.set(original.categories.all())
    
    messages.success(request, f"Created duplicate: {duplicate.title}")
    return redirect(reverse_lazy("admin:myapp_article_change", args=[duplicate.pk]))
Open external resources in new tabs:
@action(
    description="Preview",
    icon="external-link",
    variant=ActionVariant.INFO,
    attrs={"target": "_blank", "rel": "noopener noreferrer"}
)
def preview_article(self, request, object_id):
    article = self.get_object(request, object_id)
    return redirect(article.get_absolute_url())

@action(
    description="Analytics",
    icon="chart-bar",
    attrs={"target": "_blank"},
    url_path="row-analytics"
)
def view_analytics(self, request, object_id):
    article = self.get_object(request, object_id)
    return redirect(f"https://analytics.example.com/article/{article.id}")

Complete Example

Here’s a comprehensive example with multiple row actions:
admin.py
from django.contrib import admin, messages
from django.shortcuts import redirect
from django.urls import reverse_lazy
from django.utils import timezone
from unfold.admin import ModelAdmin
from unfold.decorators import action
from unfold.enums import ActionVariant

from .models import Article

@admin.register(Article)
class ArticleAdmin(ModelAdmin):
    list_display = ["title", "author", "status", "created_at"]
    actions_row = [
        "quick_view",
        "quick_edit",
        "toggle_published",
        "duplicate",
        "delete_article",
    ]

    @action(
        description="View",
        icon="eye",
        variant=ActionVariant.INFO,
        attrs={"target": "_blank"}
    )
    def quick_view(self, request, object_id):
        article = self.get_object(request, object_id)
        return redirect(article.get_absolute_url())

    @action(
        description="Edit",
        icon="pencil",
        variant=ActionVariant.PRIMARY
    )
    def quick_edit(self, request, object_id):
        return redirect(
            reverse_lazy("admin:myapp_article_change", args=[object_id])
        )

    @action(
        description="Publish/Unpublish",
        icon="power-off",
        variant=ActionVariant.WARNING,
        permissions=["myapp.change_article"]
    )
    def toggle_published(self, request, object_id):
        article = self.get_object(request, object_id)
        
        if article.status == "published":
            article.status = "draft"
            article.unpublished_at = timezone.now()
            msg = "unpublished"
        else:
            article.status = "published"
            article.published_at = timezone.now()
            msg = "published"
        
        article.save()
        messages.success(request, f"Article {msg}: {article.title}")
        return redirect(reverse_lazy("admin:myapp_article_changelist"))

    @action(
        description="Duplicate",
        icon="copy",
        variant=ActionVariant.INFO
    )
    def duplicate(self, request, object_id):
        original = self.get_object(request, object_id)
        
        duplicate = self.model.objects.create(
            title=f"{original.title} (Copy)",
            content=original.content,
            author=request.user,
            status="draft",
        )
        
        messages.success(request, f"Created: {duplicate.title}")
        return redirect(
            reverse_lazy("admin:myapp_article_change", args=[duplicate.pk])
        )

    @action(
        description="Delete",
        icon="trash",
        variant=ActionVariant.DANGER,
        permissions=["myapp.delete_article"]
    )
    def delete_article(self, request, object_id):
        article = self.get_object(request, object_id)
        title = article.title
        article.delete()
        
        messages.warning(request, f"Deleted: {title}")
        return redirect(reverse_lazy("admin:myapp_article_changelist"))

Best Practices

Row actions should be quick operations. For complex operations, redirect to the detail view:
return redirect(reverse_lazy("admin:app_model_change", args=[object_id]))
Most row actions should redirect back to the changelist:
return redirect(reverse_lazy("admin:app_model_changelist"))
Choose intuitive icons that match the action:
  • "eye" for view
  • "pencil" for edit
  • "trash" for delete
  • "copy" for duplicate
  • "check" for approve
Too many row actions can clutter the interface. Consider using 3-5 key actions:
actions_row = [
    "view",
    "edit",
    "toggle_status",
    "duplicate",
]
Always use messages to confirm the action:
messages.success(request, f"Action completed for: {obj.title}")

Row Actions vs Detail Actions

Row actions and detail actions share the same method signature but serve different purposes:
  • Row actions (actions_row): Quick operations in the list view, displayed for each row
  • Detail actions (actions_detail): Operations in the change form view, displayed at the top
The same method can be used in both if needed:
actions_row = ["duplicate"]
actions_detail = ["duplicate"]

Detail Actions

Create actions for object detail views

List Actions

Add global actions to list views

Build docs developers (and LLMs) love