Django Advanced

Advanced Django Admin: Bulk Actions, Inlines, Autocomplete, Custom Views, and Hardening

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.

DjangoZen Team Jul 11, 2026 15 min read 4 views

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 real application

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.

A powerful change list

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.

Query optimization in the change list

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.

Custom bulk actions

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.

Inline formsets

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.

Autocomplete fields

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>.

Custom filters

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

Overriding templates and adding views

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.

Custom dashboards

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.

Permissions and object-level access

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 a target

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.

Performance pitfalls

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.

Customizing the add/change form

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.

Save hooks and side effects

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.

Exporting and importing data

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.

Admin history and audit

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.

Theming and branding

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.

Editing directly in the change list

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.

Scaling the admin for large teams

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.

Feedback, confirmation, and undo

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.

When to build a custom panel instead

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.