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.
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.
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.
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 %}
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_displaymethod has@admin.displaywithordering=— otherwise users can't sort by it. - Every changelist with FKs sets
list_select_related. Watch the query count indjango-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 onlist_filterdefaults; users can edit the URL. - Inlines have
max_numandextra=0. Avoid rendering 1,000 line items into one HTML form. - Audit-critical models use
django-simple-history. The built-inLogEntryis not enough for compliance. - Site-wide branding lives on a custom
AdminSite, not on monkey-patches ofadmin.site. - The admin URL is not
/admin/in production. Pick something boring but non-default to cut bot traffic.