Turn the Django admin into a real operations console: powerful change lists, query optimization, bulk actions, inline formsets, autocomplete, custom filters and views, dashboards, object-level permissions — and how to secure and speed up the admin in production.
Most developers treat the Django admin as a throwaway CRUD scaffold and then rebuild it into a real internal tool by accident. It is far more capable than the tutorial shows: bulk actions, nested inline editing, autocomplete, custom filters, extra views, and full template overrides. This tutorial covers turning the admin into a genuine operations console — and the performance and security realities of running it in production, where it is both your most-used internal tool and a prime attack target.
The admin is a fully-featured Django app you configure declaratively through ModelAdmin classes. Almost everything about it is customizable, and teams routinely run their entire back-office on it — order fulfilment, content moderation, customer support — for years before needing anything bespoke. Understanding how far it stretches saves you from prematurely building a custom panel that reinvents what the admin already does well.
The change list is where operators live, so invest in it. Beyond model fields, list_display accepts callables and methods, letting you show computed columns — a coloured status badge, a formatted total, a link to a related object. Add list_filter, search_fields, and date_hierarchy and a bare table becomes a navigable console.
@admin.register(Order)
class OrderAdmin(admin.ModelAdmin):
list_display = ("number", "customer", "total", "status_badge", "created")
list_filter = ("status", "created")
search_fields = ("number", "customer__email")
date_hierarchy = "created"
@admin.display(description="Status")
def status_badge(self, obj):
colors = {"paid": "green", "pending": "orange", "failed": "red"}
return format_html('<b style="color:{}">{}</b>', colors.get(obj.status, "gray"), obj.status)
Use format_html, never string formatting, to build HTML in admin methods — it escapes interpolated values and prevents you from opening an XSS hole in your own back office.
The change list is a classic N+1 trap: every computed column that touches a related object fires a query per row, so a list of a hundred orders becomes hundreds of queries. Fix it with list_select_related and by overriding get_queryset to prefetch what your columns need.
list_select_related = ("customer",)
def get_queryset(self, request):
return super().get_queryset(request).select_related("customer").prefetch_related("items")
This is the single highest-impact admin optimization — an unoptimized change list on a busy model is often the slowest page in the whole app.
Admin actions run an operation over the rows an operator selects — mark orders shipped, resend confirmation emails, export to CSV. They turn the admin from a record editor into a bulk-operations tool.
@admin.action(description="Mark selected orders as shipped")
def mark_shipped(modeladmin, request, queryset):
updated = queryset.update(status="shipped")
modeladmin.message_user(request, f"{updated} orders marked shipped.")
class OrderAdmin(admin.ModelAdmin):
actions = [mark_shipped]
For destructive actions, add a confirmation step and check permissions inside the action — a bulk action that skips authorization is a privilege-escalation waiting to happen.
The permissions argument to @admin.action hides an action from anyone who fails the check, and it accepts custom names: permissions=["cancel"] makes the admin call a has_cancel_permission(request) method you define on the ModelAdmin. Combine that with an intermediate page — the same pattern Django's built-in delete_selected uses — so the operator sees exactly which rows will change before anything happens.
from django.contrib import admin, messages
from django.contrib.admin import helpers
from django.db import transaction
from django.template.response import TemplateResponse
@admin.register(Order)
class OrderAdmin(admin.ModelAdmin):
actions = ["cancel_orders"]
def has_cancel_permission(self, request):
return request.user.has_perm("shop.cancel_order")
@admin.action(description="Cancel selected orders", permissions=["cancel"])
def cancel_orders(self, request, queryset):
eligible = queryset.filter(status__in=["pending", "paid"])
if request.POST.get("confirm") == "yes":
with transaction.atomic():
ids = list(eligible.select_for_update().values_list("pk", flat=True))
cancelled = Order.objects.filter(pk__in=ids).update(status="cancelled")
skipped = queryset.count() - cancelled
self.message_user(
request, f"{cancelled} cancelled, {skipped} skipped.", messages.SUCCESS
)
return None # back to the change list
context = {
**self.admin_site.each_context(request),
"title": "Confirm cancellation",
"opts": self.model._meta,
"eligible": eligible,
"queryset": queryset,
"action_checkbox_name": helpers.ACTION_CHECKBOX_NAME,
}
return TemplateResponse(request, "admin/shop/order/confirm_cancel.html", context)
The confirmation template re-posts the selection back to the change list, together with the action name and a flag saying the operator confirmed:
{% extends "admin/base_site.html" %}
{% block content %}
<form method="post">{% csrf_token %}
<p>Cancel {{ eligible|length }} of {{ queryset|length }} selected orders?</p>
<ul>{% for order in eligible %}<li>{{ order.number }} ({{ order.status }})</li>{% endfor %}</ul>
{% for obj in queryset %}
<input type="hidden" name="{{ action_checkbox_name }}" value="{{ obj.pk }}">
{% endfor %}
<input type="hidden" name="action" value="cancel_orders">
<input type="hidden" name="confirm" value="yes">
<input type="submit" value="Yes, cancel these orders">
</form>
{% endblock %}
A few details make this production-safe. The shop.cancel_order permission is a custom one declared in the model's Meta.permissions, so you can grant it to a group without handing out general change rights. The eligibility filter runs again on the confirmed POST, so an order that was shipped between the two clicks is skipped rather than cancelled. select_for_update inside the transaction stops a concurrent action or save from racing the update. And the message reports skipped rows explicitly, because a silent partial success is how operators end up believing something happened that did not.
Remember that queryset.update() bypasses save(), model signals, and the admin's LogEntry history. If downstream code listens for post_save, or you need an audit record per order, iterate and save each object — logging it with self.log_change(request, obj, "Cancelled via bulk action") — and accept the extra queries.
Inlines let you edit related objects on the same page as their parent — order line items under an order, images under a product. TabularInline renders them as a compact grid, StackedInline as full forms.
class OrderItemInline(admin.TabularInline):
model = OrderItem
extra = 0
autocomplete_fields = ("product",)
class OrderAdmin(admin.ModelAdmin):
inlines = [OrderItemInline]
Inlines are powerful but a performance cliff: each one runs its own queries, and a page with several inlines over large relations gets slow. Set extra = 0 so you are not rendering blank spare forms, and paginate or limit inlines on high-cardinality relations.
A foreign key to a table with thousands of rows renders as an unusable dropdown that loads every option. autocomplete_fields replaces it with a search-as-you-type widget backed by AJAX, which requires search_fields on the target model's admin. This one change makes editing records with big relations bearable and stops the admin loading tens of thousands of options into a <select>.
Built-in filters cover fields; for anything computed you write a SimpleListFilter. This lets operators slice by things that are not columns — "orders over €100", "customers with no purchases", "overdue".
class HighValueFilter(admin.SimpleListFilter):
title = "order value"
parameter_name = "value"
def lookups(self, request, model_admin):
return [("high", "Over €100"), ("low", "€100 or less")]
def queryset(self, request, qs):
if self.value() == "high":
return qs.filter(total__gt=100)
if self.value() == "low":
return qs.filter(total__lte=100)
return qs
You can override any admin template by placing a file at the matching path in your templates directory, and you can add entirely new pages by overriding get_urls on a ModelAdmin. That is how you attach a custom report, a bulk-import wizard, or a one-off tool to the admin, inheriting its authentication and styling for free.
def get_urls(self):
urls = super().get_urls()
custom = [path("report/", self.admin_site.admin_view(self.report_view), name="order_report")]
return custom + urls
Wrapping your view in self.admin_site.admin_view is essential — it enforces the admin login and permission checks so your custom page is not an unauthenticated backdoor.
The default admin index is a list of models, but you can override it to build a real dashboard — today's revenue, open tickets, items needing attention. Override the index template or the AdminSite and feed it aggregates. A well-made admin dashboard often becomes the page your operations team keeps open all day.
The admin respects Django's permission framework, and you can go finer. Override has_change_permission, has_delete_permission, and get_queryset to enforce object-level rules — a regional manager sees only their region's orders, a support agent can view but not delete. Never rely on simply hiding a model from the menu; enforce access in these methods, because the URLs are guessable and hiding is not security.
The admin is the highest-value URL in your app — a foothold there is total compromise — so harden it deliberately. Move it off the default /admin/ path to cut drive-by scanning, put it behind rate limiting and account lockout, and require two-factor authentication for staff. Restrict it by IP where feasible, and ensure staff accounts follow least privilege rather than everyone being a superuser. Treating the admin as casually trusted internal software is how internal tools become breach headlines.
Start with configuration that costs nothing at runtime. Load the admin path from the environment so it differs per deployment and is not committed to the repository, and make sure the session and CSRF cookies never travel over plain HTTP.
# settings.py
import os
ADMIN_URL = os.environ.get("DJANGO_ADMIN_URL", "ops-console/")
SESSION_COOKIE_SECURE = True
CSRF_COOKIE_SECURE = True
SECURE_SSL_REDIRECT = True
SECURE_HSTS_SECONDS = 31536000
SESSION_COOKIE_AGE = 60 * 60 * 8 # one working day, not the two-week default
# urls.py
from django.conf import settings
from django.contrib import admin
from django.urls import path
urlpatterns = [
path(settings.ADMIN_URL, admin.site.urls),
# ...
]
A non-default path is noise reduction, not access control: it stops scanners from hammering your login form, but anyone who sees a link, a referrer or a browser history entry knows it. The real controls are layered on top:
| Control | Stops | Where it lives |
|---|---|---|
| Network allowlist (VPN, office range) | Everything from the open internet | Reverse proxy or firewall |
| Two-factor authentication | Stolen or reused passwords | Admin login (e.g. django-otp's OTPAdminSite) |
| Login throttling and lockout | Password guessing | A package such as django-axes, or the proxy |
| Least-privilege groups | Blast radius of any single account | Django groups and permissions |
| Short sessions, secure cookies | Session theft from shared machines or networks | Settings above |
| Login and change monitoring | Undetected misuse | Auth signals, LogEntry, log alerts |
IP restriction is most reliable in the reverse proxy, before a request ever reaches Python, where it cannot be bypassed by a view that forgot a decorator:
location /ops-console/ {
allow 10.0.0.0/24; # VPN range
allow 203.0.113.10; # office egress
deny all;
proxy_pass http://127.0.0.1:8000;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
}
If you must enforce it in Django instead, use the client address established by your trusted proxy configuration, never a raw X-Forwarded-For header taken from the client, which anyone can forge. The autocomplete endpoint and every custom admin view live under the same prefix, so an allowlist on the prefix covers them as well.
Prevention fails eventually, so make admin access observable. Django's authentication signals give you a cheap, structured log of staff logins and failed attempts that you can ship to your log platform and alert on — a burst of failures, a login from an unfamiliar network, a staff login at 3 a.m.
import logging
from django.contrib.auth.signals import user_logged_in, user_login_failed
from django.dispatch import receiver
security_log = logging.getLogger("security.admin")
def _client_ip(request):
return request.META.get("REMOTE_ADDR") if request is not None else None
@receiver(user_logged_in)
def log_staff_login(sender, request, user, **kwargs):
if user.is_staff:
security_log.info("staff_login user_id=%s ip=%s", user.pk, _client_ip(request))
@receiver(user_login_failed)
def log_failed_login(sender, credentials, request=None, **kwargs):
# Django cleanses credentials before sending; the password is not exposed
security_log.warning(
"login_failed username=%r ip=%s", credentials.get("username"), _client_ip(request)
)
Pair this with a periodic review of who holds is_superuser and is_staff. Staff accounts accumulate: people change roles, contractors leave, a "temporary" superuser from an incident two years ago is still active. A scheduled report of staff users who have not logged in for 90 days, sent to the team that owns access, closes that gap with almost no effort.
Two things quietly make the admin slow. First, an expensive __str__ on a model that renders in every dropdown and inline, multiplying cost across a page. Second, unoptimized change lists and inlines that N+1 their way to hundreds of queries. Profile the admin like any other page — django-silk or the debug toolbar reveal the query counts — and remember that operators use these pages constantly, so their slowness is felt more than any customer-facing page.
The edit form is as tunable as the list. fieldsets group and order fields into sections; readonly_fields show computed or immutable values; formfield_overrides swap widgets globally (a nicer date picker, a larger text area). For validation and cross-field rules, attach a custom ModelForm to the admin via form. Together these turn a flat, auto-generated form into something operators can use quickly and safely, with related context grouped where it belongs rather than a wall of fields.
To run logic when an operator saves — recalculate a total, send a notification, write an audit record — override save_model for the object and save_formset for its inlines. These hooks give you the request, so you can attribute changes to the acting user.
def save_model(self, request, obj, form, change):
if not change:
obj.created_by = request.user
super().save_model(request, obj, form, change)
Keep side effects here idempotent and fast; heavy work (emails, external calls) belongs in a queued task triggered from the hook, not inline in the save.
Operators constantly need data out of the admin — a CSV of this month's orders, a spreadsheet for finance. A simple export action that streams selected rows as CSV covers most needs, and django-import-export adds a full import/export UI with preview, validation, and rollback for bulk data work. Providing a first-class export path stops the recurring "can you pull me a list of…" request from becoming a manual database query every week.
The admin already records who changed what: every add, change, and delete writes a LogEntry, surfaced as the History link on each object. For anything more — field-level diffs, an audit trail you can query and report on — add a package like django-simple-history, which snapshots each version of a record. In a back office where several people edit the same data, "who changed this and when" is asked often enough that building the audit trail in from the start pays for itself.
You can rebrand the admin without abandoning it — set the site header, title, and index text on the AdminSite, and drop in a modern theme like django-admin-interface or Jazzmin for a refreshed look. This matters more than it sounds: a branded, tidy admin signals to internal users that it is a real tool worth using carefully, and it makes the jump from Django's default gray far less jarring for non-technical staff who live in it all day.
For quick bulk edits, list_editable turns chosen columns into inline form fields right on the change list, so an operator can update status or priority across many rows and save once, without opening each record. It pairs naturally with list_filter — filter to the rows that need attention, edit them in place, save. Keep editable fields few and cheap; making a heavy or validated field list-editable pushes work into a page already prone to N+1, so reserve it for simple, high-frequency edits where the time saved is real.
As more people use the admin, one flat configuration strains. You can register the same models on multiple AdminSite instances with different configurations — a lean, restricted site for support agents and a full one for engineers — so each team sees a tool shaped for its job rather than every model and action at once. Combined with strict per-model permissions, this keeps the admin usable and safe as the team grows, instead of everyone sharing one sprawling superuser view where the risk of a wrong click scales with the headcount.
Good internal tools tell the operator what happened and guard destructive steps. Use message_user to report the outcome of actions ("42 orders shipped, 3 skipped"), add an intermediate confirmation page for anything irreversible, and prefer soft-delete or status changes over hard deletes so a mistake is recoverable. The admin makes deleting one click away, and in a busy back office that is exactly how data gets lost — building in confirmation and recoverability is what separates a tool people trust from one they fear.
Admin code is easy to break without noticing, because few teams write tests for configuration and operators discover the breakage on Monday morning. A handful of generic tests catches most regressions: every registered change list and add page renders, search works, query counts stay flat as data grows, and each custom action does what it claims.
from django.contrib import admin
from django.contrib.auth import get_user_model
from django.db import connection
from django.test import TestCase
from django.test.utils import CaptureQueriesContext
from django.urls import reverse
class AdminSmokeTests(TestCase):
@classmethod
def setUpTestData(cls):
cls.superuser = get_user_model().objects.create_superuser(
"root", "root@example.com", "unused-password"
)
def setUp(self):
self.client.force_login(self.superuser)
def test_every_changelist_renders_and_searches(self):
for model, model_admin in admin.site._registry.items():
opts = model._meta
with self.subTest(model=opts.label):
url = reverse(f"admin:{opts.app_label}_{opts.model_name}_changelist")
self.assertEqual(self.client.get(url).status_code, 200)
if model_admin.search_fields:
self.assertEqual(self.client.get(url, {"q": "x"}).status_code, 200)
add_url = reverse(f"admin:{opts.app_label}_{opts.model_name}_add")
self.assertIn(self.client.get(add_url).status_code, (200, 403))
def test_order_changelist_query_count_is_flat(self):
url = reverse("admin:shop_order_changelist")
make_orders(3)
with CaptureQueriesContext(connection) as few:
self.client.get(url)
make_orders(40)
with CaptureQueriesContext(connection) as many:
self.client.get(url)
self.assertEqual(len(few), len(many))
def test_mark_shipped_action(self):
order = make_orders(1)[0]
self.client.post(
reverse("admin:shop_order_changelist"),
{"action": "mark_shipped", "_selected_action": [order.pk]},
)
order.refresh_from_db()
self.assertEqual(order.status, "shipped")
The flat-query-count test is the one that pays for itself: it fails the moment someone adds a computed column that touches a relation without prefetching it, and it does so without hard-coding a brittle exact number. Create the test orders on the same date so date_hierarchy does not add queries for extra years or months. admin.site._registry is technically private but stable and widely used for exactly this; if you run several admin sites, loop over each. Finally, run manage.py check in CI — the admin's system checks catch misconfigured list_display, autocomplete_fields, list_filter and inline declarations before they reach a browser.
| Symptom | Cause | Fix |
|---|---|---|
| Custom admin URL redirects with "does not exist" | Custom path listed after the default URLs, so the object-id pattern matches first | Return custom + urls, never urls + custom |
| Autocomplete widget stays empty | User lacks view permission on the target model, or the target admin's get_queryset filters everything out | Grant view permission; check the target admin's queryset logic |
Check error admin.E039 or admin.E040 | Autocomplete target not registered, or registered without search_fields | Register the target admin with search_fields |
| Custom action missing from the dropdown | Its permissions check returns False, or the change list is empty (the action bar is hidden when there are no rows) | Verify the has_<name>_permission method and the user's groups |
| Confirmation page fails with a CSRF 403 | Intermediate form missing {% csrf_token %} | Add the token to the form |
| Template override ignored | App listed after django.contrib.admin, or wrong path (admin/<app_label>/<model_name>/) | Reorder INSTALLED_APPS or use project DIRS; the model name is lowercase |
Push the admin far, but know its ceiling. When you need a workflow the object-per-page model fights — multi-step wizards, heavily custom UX, dashboards that are the primary product — a purpose-built internal app is cleaner than bending the admin past breaking. The right call is usually to ride the admin as long as it fits and graduate specific workflows to custom views only when the friction is real, not to rebuild from scratch on day one.