How-to
Automating Reporting WorkflowsDeep dive

Add an Export to Excel Action in Django Admin

Give every Django admin changelist a proper Excel export: a reusable ModelAdmin mixin that turns list_display into columns with their admin labels, readable choice and boolean values, local dates, an action for selected rows and a button that exports everything matching the current filters, a dedicated export permission, a row limit, and tests with the Django test client.

Sooner or later someone asks for "the admin list, but in Excel". The quick answer is an admin action that dumps the selected objects, and it is covered briefly in Export a Django Queryset to Excel. Done properly for an admin used every day, though, the export should match what people see on the changelist — the same columns with the same headings, "Paid" instead of paid, local times instead of UTC — work for every model without copy-pasting, export everything matching the current filters and search (not just the 100 rows on screen), and be limited to the people allowed to take data out of the system. This page builds that as a reusable mixin, adds a custom permission and a row limit, and tests it with the Django test client. It belongs to Serving Excel Files from Python Web Apps.

Prerequisites

Bash
pip install django openpyxl

The examples use a small shop app with customers and orders. The code was tested with Django 6.1; the admin APIs it relies on — label_for_field, lookup_field, action permissions and get_changelist_instance — have been stable for many releases.

Python
from django.db import models

class Customer(models.Model):
    name = models.CharField(max_length=100)
    country = models.CharField(max_length=2)

    def __str__(self):
        return self.name

class Order(models.Model):
    class Status(models.TextChoices):
        OPEN = "open", "Open"
        PAID = "paid", "Paid"
        CANCELLED = "cancelled", "Cancelled"

    number = models.CharField("Order no.", max_length=20, unique=True)
    customer = models.ForeignKey(Customer, on_delete=models.PROTECT)
    status = models.CharField(max_length=10, choices=Status.choices, default=Status.OPEN)
    total = models.DecimalField(max_digits=10, decimal_places=2)
    paid = models.BooleanField(default=False)
    created = models.DateTimeField()

    class Meta:
        permissions = [("export_order", "Can export orders to Excel")]

The export_order permission in Meta.permissions is what the export will check, so being able to view orders in the admin does not automatically mean being able to download all of them.

Step 1 — A mixin that exports list_display

The mixin reads the columns from the admin's own list_display, so an export always matches the changelist. Django's admin utilities do the hard work: label_for_field gives the column label the admin uses (the field's verbose_name, which the admin capitalises with capfirst when it renders the heading), and lookup_field resolves a field name, a related lookup or an admin method to a value:

Python
import logging
from datetime import datetime
from io import BytesIO

from django.contrib import admin, messages
from django.contrib.admin.utils import label_for_field, lookup_field
from django.http import HttpResponse
from django.urls import path
from django.utils import timezone
from django.utils.text import capfirst
from openpyxl import Workbook
from openpyxl.styles import Font
from openpyxl.utils import get_column_letter

log = logging.getLogger("admin.export")
XLSX = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"

def excel_value(field, value):
    if field is not None and getattr(field, "flatchoices", None):
        return str(dict(field.flatchoices).get(value, value))
    if isinstance(value, bool):
        return "Yes" if value else "No"
    if isinstance(value, datetime):
        return timezone.localtime(value).replace(tzinfo=None) if timezone.is_aware(value) else value
    if value is None or isinstance(value, (int, float)) or hasattr(value, "as_tuple"):
        return value
    text = str(value)
    return "'" + text if text[:1] in ("=", "+", "-", "@") else text

class ExportXlsxMixin:
    export_fields = None          # defaults to list_display
    export_max_rows = 50_000

    def get_export_fields(self, request):
        fields = self.export_fields or self.get_list_display(request)
        return [f for f in fields if f != "action_checkbox"]

    def has_export_permission(self, request):
        return request.user.has_perm(f"{self.opts.app_label}.export_{self.opts.model_name}")

    def build_workbook(self, request, queryset):
        fields = self.get_export_fields(request)
        wb = Workbook()
        ws = wb.active
        ws.title = str(self.opts.verbose_name_plural)[:31]
        ws.append([capfirst(str(label_for_field(f, self.model, self))) for f in fields])
        for obj in queryset.iterator(chunk_size=2000):
            row = []
            for name in fields:
                field, _attr, value = lookup_field(name, obj, self)
                row.append(excel_value(field, value))
            ws.append(row)
        for i, name in enumerate(fields, start=1):
            ws.cell(1, i).font = Font(bold=True)
            ws.column_dimensions[get_column_letter(i)].width = 18
        ws.freeze_panes = "A2"
        ws.auto_filter.ref = ws.dimensions
        buf = BytesIO()
        wb.save(buf)
        return buf.getvalue()

    def xlsx_response(self, request, queryset, scope):
        count = queryset.count()
        if count > self.export_max_rows:
            self.message_user(request, f"{count:,} rows is more than the export limit of "
                                       f"{self.export_max_rows:,}. Narrow the filters first.", messages.ERROR)
            return None
        log.info("%s exported %d %s (%s)", request.user, count, self.opts.verbose_name_plural, scope)
        stamp = timezone.localtime().strftime("%Y-%m-%d_%H%M")
        response = HttpResponse(self.build_workbook(request, queryset), content_type=XLSX)
        response["Content-Disposition"] = f'attachment; filename="{self.opts.model_name}s_{stamp}.xlsx"'
        return response

    @admin.action(description="Export selected to Excel", permissions=["export"])
    def export_xlsx(self, request, queryset):
        return self.xlsx_response(request, queryset, "selected")

lookup_field returns the model field (or None for an admin method), the attribute and the value, so excel_value can turn a choice code into its label using the field's flatchoices, booleans into Yes/No, and aware datetimes into naive local times — Excel has no time zones, and openpyxl refuses aware datetimes. Numbers and Decimal values pass through as numbers so they can be summed in Excel. Text that starts with =, +, - or @ gets an apostrophe so Excel does not treat a customer's name as a formula; the reasoning is in Prevent Formula Injection in Excel Exports. permissions=["export"] on the action makes Django call has_export_permission and hide the action from anyone without it. Returning None from an action redirects back to the changelist, where the error message appears.

From list_display to workbook columns Each entry in list_display becomes a column. Its heading comes from the admin label, such as Order no. for the number field and Country for an admin method. Values are converted for Excel, so the status code paid becomes Paid, True becomes Yes and an aware UTC datetime becomes local time. A B 1 entry raw value 2 number SO-1002 3 customer_country GB 4 status paid 5 paid True 6 created 2026-09-03 07:15Z heading cell Order no. SO-1002 Country GB Status Paid Paid Yes Created 03/09/2026 08:15 list_display Excel column label_for_field + lookup_field

Step 2 — Export everything matching the filters

An action only sees the rows ticked on the current page. To export everything that matches the filters and search box, add a URL to the model admin and build the queryset the same way the changelist does:

Python
from django.core.exceptions import PermissionDenied
from django.shortcuts import redirect
from django.urls import path

from .exports import ExportXlsxMixin

class FilteredExportMixin(ExportXlsxMixin):
    change_list_template = "admin/export_change_list.html"

    def get_urls(self):
        name = f"{self.opts.app_label}_{self.opts.model_name}_export"
        return [path("export/", self.admin_site.admin_view(self.export_filtered_view), name=name)] + super().get_urls()

    def export_filtered_view(self, request):
        if not self.has_export_permission(request):
            raise PermissionDenied
        changelist = self.get_changelist_instance(request)
        queryset = changelist.get_queryset(request)
        return self.xlsx_response(request, queryset, "filtered") or redirect(
            f"../?{request.GET.urlencode()}")

get_changelist_instance(request) builds the same ChangeList object the admin uses to render the page, from the request's query string — list filters, date hierarchy, search terms and ordering included — so changelist.get_queryset(request) returns exactly the rows the user is looking at across all pages. The view is wrapped in admin_site.admin_view, which requires a logged-in staff user, and it checks the export permission itself. A small template adds a button to the changelist that carries the current query string along:

Html
{% extends "admin/change_list.html" %}
{% block object-tools-items %}
  {{ block.super }}
  <li><a href="export/?{{ request.GET.urlencode }}" class="historylink">Export to Excel</a></li>
{% endblock %}

Step 3 — Register a model

With the mixins in place, enabling the export on a model admin is one base class and one line in actions:

Python
from django.contrib import admin

from .exports_filtered import FilteredExportMixin
from .models import Order

@admin.register(Order)
class OrderAdmin(FilteredExportMixin, admin.ModelAdmin):
    list_display = ["number", "customer", "customer_country", "status", "total", "paid", "created"]
    list_filter = ["status", "paid"]
    search_fields = ["number", "customer__name"]
    actions = ["export_xlsx"]

    @admin.display(description="Country", ordering="customer__country")
    def customer_country(self, obj):
        return obj.customer.country

    def get_queryset(self, request):
        return super().get_queryset(request).select_related("customer")

customer_country is an admin method with a description, which becomes both the changelist heading and the Excel heading. select_related("customer") matters twice over: the changelist and the export both read the customer for every row, and without it each row costs an extra query. If an export needs columns that the changelist does not show — an internal ID, an address — set export_fields on the admin instead of changing list_display. Making a large export run in the background instead of inside the request is covered in Generate Large Excel Exports in a Background Job.

What happens when someone clicks Export to Excel The button sends the changelist's query string to the export URL. admin_view requires a staff login, the view checks the export permission, the changelist rebuilds the filtered queryset, the row limit is checked, and the workbook is returned as an attachment. Button export/ + filters admin_view staff login required Permission shop.export_order ChangeList queryset filters, search, ordering Workbook attachment response

Step 4 — Test it with the Django test client

The tests log in as two staff users — one with the export permission, one without — and exercise both the action and the filtered export:

Python
from io import BytesIO
from django.contrib.auth.models import Permission, User
from django.test import Client
from openpyxl import load_workbook

viewer = User.objects.create_user("viewer", password="pw", is_staff=True)
viewer.user_permissions.add(Permission.objects.get(codename="view_order"))
exporter = User.objects.create_user("exporter", password="pw", is_staff=True)
exporter.user_permissions.add(*Permission.objects.filter(codename__in=["view_order", "export_order"]))

def rows(response):
    ws = load_workbook(BytesIO(response.content)).active
    return [list(r) for r in ws.iter_rows(values_only=True)]

c = Client()
c.login(username="viewer", password="pw")
r = c.post("/admin/shop/order/", {"action": "export_xlsx", "_selected_action": ["1", "2"]})
print("viewer, action:", r.status_code, r.get("Content-Type"))
print("viewer, filtered export:", c.get("/admin/shop/order/export/").status_code)

c.login(username="exporter", password="pw")
r = c.post("/admin/shop/order/", {"action": "export_xlsx", "_selected_action": ["1", "2"]})
print("exporter, action:", r.status_code, r["Content-Disposition"].split(";")[0])
for row in rows(r):
    print("  ", row)
r = c.get("/admin/shop/order/export/", {"status__exact": "paid"})
print("filtered to paid:", [row[0] for row in rows(r)[1:]])
print("export button shown:", "Export to Excel" in c.get("/admin/shop/order/").content.decode())

The viewer can see orders but gets no Excel file: the action is not in their list (the POST simply returns the changelist page) and the filtered export URL answers 403. The exporter's action returns an attachment whose first row holds the admin headings — Order no., Customer, Country, Status, Total, Paid, Created — followed by the two selected orders with Paid and Yes instead of codes, times converted from 07:15 UTC to 08:15 London time, and the customer named =Café Señor safely prefixed with an apostrophe. The filtered export with status__exact=paid returns both paid orders, regardless of page or selection, and the changelist shows the button. These are the assertions to keep in the project's test suite.

Who can export what A staff user with only the view permission sees the changelist but has no export action, and the filtered export URL returns 403. A user who also has the export permission sees the action and the button and receives the workbook. view changelist export action filtered export viewer (view_order) ✓ hidden 403 exporter (+ export_order) ✓ ✓ xlsx ✓ xlsx

Common pitfalls

  • Exporting raw field values. Choice codes, True and UTC times confuse readers; convert as the admin displays them.
  • Only exporting the selected page. Use the changelist's queryset for "everything matching".
  • No separate permission. Viewing a list and downloading all of it are different rights.
  • Aware datetimes in openpyxl. Convert to local time and drop the zone.
  • Unbounded exports in a request. Set a row limit and move big exports to a background job.

Conclusion

A reusable ExportXlsxMixin gives every Django admin an Excel export that matches its changelist: columns and headings from list_display via label_for_field and lookup_field, readable choice, boolean and local-time values, and formula-safe text. Add an action for selected rows and an export URL that rebuilds the filtered queryset with get_changelist_instance, guard both with a dedicated export_<model> permission and a row limit, and test them with the Django test client as users with and without the permission.

Frequently asked questions

How do I add an export to Excel action to the Django admin? Write an admin action decorated with admin.action that builds a workbook from the queryset with openpyxl and returns an HttpResponse with the xlsx content type and an attachment Content-Disposition, then list it in the ModelAdmin's actions.

How do I export all filtered rows, not just the selected ones? Add a URL to the ModelAdmin with get_urls and, in its view, build the queryset with get_changelist_instance and then its get_queryset method, which applies the current filters, search and ordering from the query string.

How do I restrict who can export from the admin? Add a custom permission in the model's Meta, define has_export_permission on the ModelAdmin, and pass permissions="export" to the action decorator. Check the same permission in any export view.

Why are times in my Django Excel export wrong? Django stores aware UTC datetimes. Convert each to local time with timezone.localtime and remove the tzinfo before writing, since Excel has no time zones.

More on excel in web apps

More on excel in web apps →