WEBBYFOX-OS PATH /blog/django-admin-customization-tricks/ DOC ~14 MIN NODE LDN-01
FEED ACTIVE 13:42 BST
./post · django-admin-customization-tricks.md
READ
// ARTICLE · FEB 14, 2026

Django Admin Customization Tricks: Beyond ModelAdmin.

Out of the box, the Django admin is the best free internal tool any framework gives you. After ten years building admin-heavy products, the same set of patterns keeps coming up — and they're all built on stock ModelAdmin, no third-party skin required. This post collects the ones that have earned their keep: list filters that actually answer business questions, bulk actions that scale, custom admin views for one-off workflows, performance fixes, and an audit log you can defend.

Python Django Admin
· ~14 min read · Rizwan Mansuri
$ cat ./django-admin-customization-tricks.md
READ

Why Bother With the Admin

A common pattern I see: a team spends three months building a bespoke React back-office while the Django admin sits unused. Three months in, they have 20% of what the admin already gave them and they're maintaining two codebases. Before you write a custom back-office, ask whether 15 lines of ModelAdmin would do the job.

The admin shines for internal tools: support, content moderation, sales ops, finance reconciliation. It's free, comes with auth, has CSRF and audit primitives baked in, and every Django developer can read it. The trick is to push it further than the default "register the model and walk away" pattern.


Display Methods That Scale

The default list_display shows raw field values. With @admin.display decorators you can show derived data, format dates, render badges, and link to related records — without giving up sorting or admin search.

from django.contrib import admin
from django.utils.html import format_html
from django.urls import reverse


@admin.register(Order)
class OrderAdmin(admin.ModelAdmin):
    list_display = ("ref", "customer_link", "total_formatted", "status_badge", "placed_at")
    list_select_related = ("customer",)   # see §7 — no N+1
    ordering = ("-placed_at",)

    @admin.display(description="Customer", ordering="customer__name")
    def customer_link(self, obj):
        url = reverse("admin:shop_customer_change", args=[obj.customer_id])
        return format_html('<a href="{}">{}</a>', url, obj.customer.name)

    @admin.display(description="Total", ordering="total")
    def total_formatted(self, obj):
        return f"£{obj.total:,.2f}"

    @admin.display(description="Status", ordering="status")
    def status_badge(self, obj):
        colors = {"paid": "#00b07a", "pending": "#f4b73d", "refunded": "#ff5575"}
        return format_html(
            '<span style="padding:2px 8px;border-radius:3px;'
            'background:{};color:#fff;font-size:11px">{}</span>',
            colors.get(obj.status, "#666"), obj.get_status_display(),
        )

Two things doing the work here. The ordering= argument on @admin.display tells the admin which DB column to sort by when the user clicks the column header — without it, custom display methods are unsortable. format_html() auto-escapes its arguments while letting your wrapper markup through, which is what you want for badges and links.

Django administration rmansuri / Log out Home › Shop › Orders Select order to change REF ▾ CUSTOMER TOTAL STATUS PLACED AT WF-10042 Acme Logistics Ltd. £1,248.50 paid 14 Feb 2026 09:12 WF-10041 Berkeley Catering Co. £392.00 pending 14 Feb 2026 08:54 WF-10040 Cromwell Hardware £2,975.20 paid 13 Feb 2026 18:30 WF-10039 Dovetail Studios £148.00 refunded 13 Feb 2026 14:02 4 orders · sorted by placed_at (desc)
The result of the OrderAdmin.list_display code above — clickable customer links, money formatted with the £ symbol, and coloured status badges. Each badge column is still sortable because @admin.display declares ordering="status".

Custom List Filters

Django's built-in filters cover status fields and dates. The interesting work is filters that answer business questions: "show me orders that have shipped but not been invoiced", "show me customers with no orders in 90 days".

from django.contrib import admin
from django.utils import timezone
from datetime import timedelta


class StaleCustomerFilter(admin.SimpleListFilter):
    title = "activity"
    parameter_name = "activity"

    def lookups(self, request, model_admin):
        return [
            ("active",   "Active (order in 30 days)"),
            ("idle",     "Idle (30–90 days)"),
            ("dormant",  "Dormant (no order in 90 days)"),
            ("never",    "Never ordered"),
        ]

    def queryset(self, request, queryset):
        now = timezone.now()
        d30 = now - timedelta(days=30)
        d90 = now - timedelta(days=90)
        v = self.value()
        if v == "active":
            return queryset.filter(orders__placed_at__gte=d30).distinct()
        if v == "idle":
            return queryset.filter(orders__placed_at__lt=d30,
                                   orders__placed_at__gte=d90).distinct()
        if v == "dormant":
            return queryset.filter(orders__placed_at__lt=d90)\
                           .exclude(orders__placed_at__gte=d90).distinct()
        if v == "never":
            return queryset.filter(orders__isnull=True)
        return queryset


@admin.register(Customer)
class CustomerAdmin(admin.ModelAdmin):
    list_filter = (StaleCustomerFilter, "country", "created_at")

SimpleListFilter is enough for 95% of cases. For filters that need to span multiple parameters (a date range, say), subclass FieldListFilter instead — it gives you full control over the rendered HTML and the URL params.

Customers Filtered: activity = Dormant (no order in 90 days) NAME EMAIL LAST ORDER Holborn Health Foods Ltd hello@holbornhf.co.uk 2 Sep 2025 Inkwell Print & Bind ops@inkwell.io 18 Aug 2025 Junipero Cycle Co. sales@junipero.cc 06 Aug 2025 Karat Goldsmiths admin@karat.uk 23 Jul 2025 Larkspur Florists hello@larkspur.flowers 11 Jul 2025 …and 47 more FILTER BY ACTIVITY All Active (order in 30 days) Idle (30–90 days) Dormant (no order in 90 days) Never ordered BY COUNTRY All · UK · IE · US · DE … BY CREATED AT Any · Today · Past 7 days …
The right-hand filter sidebar with StaleCustomerFilter registered. The four lookups() entries appear as clickable rows; the active selection is highlighted. URL state syncs as ?activity=dormant.

Bulk Actions, Done Right

Admin actions get one thing wrong by default: they're synchronous. If your "mark 1,000 orders as shipped" action loops through and calls .save() 1,000 times, you'll burn 30 seconds of worker time and trigger every post_save signal individually. Bulk operations are a one-line fix:

from django.contrib import admin, messages
from django.utils import timezone


@admin.action(description="Mark selected orders as shipped")
def mark_shipped(modeladmin, request, queryset):
    updated = queryset.filter(status="paid").update(
        status="shipped",
        shipped_at=timezone.now(),
    )
    skipped = queryset.exclude(status="paid").count()
    messages.success(request, f"{updated} orders marked shipped.")
    if skipped:
        messages.warning(request, f"{skipped} skipped (not in 'paid' status).")


@admin.register(Order)
class OrderAdmin(admin.ModelAdmin):
    actions = [mark_shipped]

Three small things matter here. queryset.update() hits the DB once regardless of selection size. We filter the queryset to the valid subset rather than raising — so a user who selects a mixed batch sees a useful "X skipped" message instead of an error. And we use messages to give explicit feedback, which the default action machinery doesn't always do.

✓ 12 orders marked shipped. ⚠ 3 skipped (not in 'paid' status). Action: Mark selected orders as shipped ▾ Go 12 of 12 selected REF CUSTOMER TOTAL STATUS WF-10042 Acme Logistics Ltd. £1,248.50 shipped WF-10040 Cromwell Hardware £2,975.20 shipped
The full action loop in one frame: the dropdown shows mark_shipped (description from the @admin.action decorator), the row count reflects the user's selection, and after Go the green and amber message banners surface the two messages.success/warning calls.

Long-running actions: hand off to Celery

When the work genuinely takes more than a couple of seconds — re-encoding images, regenerating PDFs, calling an LLM — push it onto a Celery queue and return immediately:

from .tasks import regenerate_invoices_task


@admin.action(description="Regenerate invoices (async)")
def regenerate_invoices(modeladmin, request, queryset):
    ids = list(queryset.values_list("id", flat=True))
    regenerate_invoices_task.delay(ids, requested_by=request.user.id)
    messages.success(
        request,
        f"Queued regeneration of {len(ids)} invoices. "
        f"Check the Task Log in 1–2 minutes.",
    )

Pair this with a tiny TaskLog model the action writes a row to, exposed as its own admin. Users get a record of what was queued, by whom, and the eventual result — without having to peek into Flower or the Celery worker logs.


Custom Admin Views

Not everything fits inside a ModelAdmin. Reconciliation reports, manual data fixers, dashboards, CSV exporters — these belong as views inside the admin, with the admin's chrome and permission checks, but their own URLs and templates.

from django.contrib import admin
from django.shortcuts import render
from django.urls import path
from django.contrib.admin.views.decorators import staff_member_required
from django.db.models import Count, Sum


@admin.register(Order)
class OrderAdmin(admin.ModelAdmin):
    # ... list_display, list_filter, actions ...

    def get_urls(self):
        urls = super().get_urls()
        custom = [
            path(
                "daily-summary/",
                self.admin_site.admin_view(self.daily_summary_view),
                name="orders_daily_summary",
            ),
        ]
        return custom + urls

    def daily_summary_view(self, request):
        rows = (Order.objects
                .extra(select={"d": "date(placed_at)"})
                .values("d")
                .annotate(n=Count("id"), revenue=Sum("total"))
                .order_by("-d")[:30])
        context = {
            **self.admin_site.each_context(request),
            "title": "Daily Order Summary",
            "rows": rows,
        }
        return render(request, "admin/orders/daily_summary.html", context)

Two important details. self.admin_site.admin_view(view) wraps the view in the admin's auth check — without it, anyone can hit the URL. And self.admin_site.each_context(request) gives the template the same breadcrumbs, app menu, and CSS variables every other admin page uses, so the custom page doesn't look bolted on.

Link to it from the changelist with a small template override:

{# templates/admin/shop/order/change_list.html #}
{% extends "admin/change_list.html" %}

{% block object-tools-items %}
  <li>
    <a href="{% url 'admin:orders_daily_summary' %}" class="addlink">
      Daily summary
    </a>
  </li>
  {{ block.super }}
{% endblock %}
Django administration rmansuri / Log out Home › Shop › Orders › Daily Summary Daily Order Summary Last 30 days · grouped by date(placed_at) DATE ORDERS REVENUE AVG ORDER 2026-02-14 47 £18,420.30 £391.92 2026-02-13 52 £21,098.75 £405.74 2026-02-12 38 £14,205.10 £373.82 2026-02-11 61 £27,830.90 £456.24 2026-02-10 44 £17,560.00 £399.09 …25 more rows
The custom view rendered with the native admin shell — header, breadcrumb, and CSS variables all come from self.admin_site.each_context(request). Nothing else is custom; the rest is just a Django template.

Custom AdminSite & Branding

When a tenant or client asks for "the same admin but with our logo and a different title", you don't need a third-party theme. Subclass AdminSite:

# shop/admin_site.py
from django.contrib.admin import AdminSite


class ShopAdminSite(AdminSite):
    site_header = "Acme Shop · Operations"
    site_title  = "Acme Ops"
    index_title = "Operations Dashboard"
    site_url    = "/"   # "View site" link on the navbar

    def get_app_list(self, request, app_label=None):
        # Pin "Orders" to the top regardless of alphabetical order
        app_list = super().get_app_list(request, app_label)
        order = {"shop": 0, "billing": 1, "auth": 9}
        app_list.sort(key=lambda a: order.get(a["app_label"], 5))
        return app_list


shop_admin = ShopAdminSite(name="shop_admin")
# shop/urls.py — mount the custom admin at /ops/
from django.urls import path
from .admin_site import shop_admin

urlpatterns = [
    path("ops/", shop_admin.urls),
    # ...
]

You then register your ModelAdmins against shop_admin instead of the default admin.site. A useful side-effect: you can run two parallel admins — one for staff, one for partners — each with a different set of models registered.


Performance: Fix the N+1 Before It Bites

The single most common admin performance bug: a list_display method that walks a foreign key, causing one extra query per row. With 100 rows per page that's 101 queries instead of 1 — and Django won't warn you about it.

@admin.register(Order)
class OrderAdmin(admin.ModelAdmin):
    list_display = ("ref", "customer", "warehouse", "status")

    # Eager-load FKs that appear in list_display
    list_select_related = ("customer", "warehouse")

    # For reverse FKs / m2m used in list_display methods
    def get_queryset(self, request):
        qs = super().get_queryset(request)
        return qs.prefetch_related("items__product")

list_select_related covers forward foreign keys in the changelist. get_queryset with prefetch_related covers reverse and many-to-many. Add django-debug-toolbar to your dev environment, watch the query count on every admin page, and fix it the first time it crosses 20.

Search that doesn't melt the database

Default search_fields = ("name", "email") uses ILIKE '%query%', which can't use a normal B-tree index. On a 500k-row Customer table that's a sequential scan on every keystroke. Two fixes:

from django.contrib.postgres.search import TrigramSimilarity

class CustomerAdmin(admin.ModelAdmin):
    search_fields = ("name__istartswith", "email__istartswith")
    # __istartswith uses an index — much faster than the default contains match.

    # Alternative for fuzzy match — needs pg_trgm extension + a GIN index
    def get_search_results(self, request, queryset, search_term):
        if not search_term:
            return super().get_search_results(request, queryset, search_term)
        qs = (queryset
              .annotate(sim=TrigramSimilarity("name", search_term))
              .filter(sim__gt=0.2)
              .order_by("-sim"))
        return qs, False

Permissions & Read-Only Patterns

Out of the box, the admin gives you four permissions per model: add, change, delete, view. The fine-grained patterns that come up in practice — "this user can see orders but only edit ones in their region", "this user can refund up to £100 without an approval flag" — need a few helpers.

class RegionalOrderAdmin(admin.ModelAdmin):
    list_display = ("ref", "region", "total", "status")

    def get_queryset(self, request):
        qs = super().get_queryset(request)
        if request.user.is_superuser:
            return qs
        return qs.filter(region=request.user.profile.region)

    def has_change_permission(self, request, obj=None):
        if not super().has_change_permission(request, obj):
            return False
        if obj is None or request.user.is_superuser:
            return True
        return obj.region == request.user.profile.region

    def get_readonly_fields(self, request, obj=None):
        ro = list(super().get_readonly_fields(request, obj))
        if obj and obj.status == "shipped":
            ro += ["total", "items"]   # locked once shipped
        return ro

Three patterns to remember: get_queryset hides what the user shouldn't see at all, has_change_permission per-object stops them editing what they can see, and get_readonly_fields conditionally locks individual fields based on object state. Layer them — none alone is enough.


Inlines & Form Overrides

Inlines are how you edit related rows without leaving the parent page. They look simple, but two things consistently catch people out: pagination (inlines don't paginate — 5,000 line items on one page is a real bug) and the query count.

class OrderItemInline(admin.TabularInline):
    model = OrderItem
    extra = 0
    fields = ("product", "qty", "unit_price", "line_total")
    readonly_fields = ("line_total",)
    autocomplete_fields = ("product",)  # avoid loading 50k products into a <select>
    max_num = 50                         # don't render more than 50 rows


@admin.register(Order)
class OrderAdmin(admin.ModelAdmin):
    inlines = [OrderItemInline]
    autocomplete_fields = ("customer",)

    fieldsets = (
        ("Customer",   {"fields": ("customer", "billing_address")}),
        ("Order",      {"fields": ("ref", "placed_at", "status")}),
        ("Money",      {"fields": ("subtotal", "tax", "total"),
                         "classes": ("collapse",)}),
        ("Internal",   {"fields": ("notes", "tags"),
                         "classes": ("collapse",)}),
    )

autocomplete_fields is non-negotiable for any foreign key pointing at a table with more than a few hundred rows — without it, the admin renders a <select> with every option, which can hit several megabytes of HTML on large reference tables. fieldsets + classes: ("collapse",) hide rarely-used fields by default, keeping the form readable.

Overriding form widgets globally

from django.contrib import admin
from django.db import models
from django.forms import Textarea


class CompactTextareaAdmin(admin.ModelAdmin):
    formfield_overrides = {
        models.TextField: {"widget": Textarea(attrs={"rows": 4, "cols": 80})},
    }

Audit Trail You Can Defend

Django's built-in LogEntry records add/change/delete actions in the admin. It's enough for "who deleted the customer?" but not enough for "what did this order look like 30 minutes ago?" — it doesn't store the field values. Two layers of improvement:

1. Surface LogEntry per-object

from django.contrib.admin.models import LogEntry
from django.contrib.contenttypes.models import ContentType


class OrderAdmin(admin.ModelAdmin):
    readonly_fields = ("recent_admin_log",)

    @admin.display(description="Recent admin activity")
    def recent_admin_log(self, obj):
        if not obj.pk:
            return "—"
        ct = ContentType.objects.get_for_model(obj)
        entries = (LogEntry.objects
                   .filter(content_type=ct, object_id=str(obj.pk))
                   .select_related("user")
                   .order_by("-action_time")[:10])
        rows = "".join(
            f"<li>{e.action_time:%Y-%m-%d %H:%M} · {e.user} · {e.get_change_message()}</li>"
            for e in entries
        )
        return format_html("<ul style='margin:0;padding-left:18px'>{}</ul>",
                           format_html(rows))

2. Snapshot field-level changes with django-simple-history

For real audit — "show me the old value of every field that changed" — drop in django-simple-history. Three lines of model integration and you get a parallel _history table that records every save, with a built-in admin inline for diffing past versions.

# pip install django-simple-history
# add 'simple_history' to INSTALLED_APPS

from simple_history.models import HistoricalRecords
from simple_history.admin import SimpleHistoryAdmin


class Order(models.Model):
    # ... fields ...
    history = HistoricalRecords()


@admin.register(Order)
class OrderAdmin(SimpleHistoryAdmin):
    list_display = ("ref", "customer", "status", "total")
    history_list_display = ("status",)   # show "status" column in the history list

The "History" link appears in the top-right of every change form. Click any past revision to see the full snapshot with a side-by-side diff against the current row. Compliance reviewers love it; you'll wonder how you ever shipped without it.


Production Checklist

  • Every list_display method has @admin.display with ordering= — otherwise users can't sort by it.
  • Every changelist with FKs sets list_select_related. Watch the query count in django-debug-toolbar; aim for <20 per admin page.
  • Every FK to a table with >500 rows uses autocomplete_fields. The default <select> dropdown gets unusable fast.
  • Bulk actions use queryset.update(), not a loop with .save().
  • Anything longer than 2 seconds is a Celery task, not a synchronous action that times out the request.
  • Custom views are wrapped in self.admin_site.admin_view(). Without it, the URL is public.
  • Field-level access is controlled with get_readonly_fields(request, obj) — not by hiding fields, which is a UX trap, not a permission control.
  • Per-tenant or per-region filtering goes in get_queryset(). Don't rely on list_filter defaults; users can edit the URL.
  • Inlines have max_num and extra=0. Avoid rendering 1,000 line items into one HTML form.
  • Audit-critical models use django-simple-history. The built-in LogEntry is not enough for compliance.
  • Site-wide branding lives on a custom AdminSite, not on monkey-patches of admin.site.
  • The admin URL is not /admin/ in production. Pick something boring but non-default to cut bot traffic.
$ ls ./related/
3 POSTS
$ cd ../  ·  · Rizwan Mansuri ↗ RSS feed · ↑ top