Filter concepts by levelShowing all levels.

Django · Django Admin

Django Admin

ModelAdmin, inlines, custom actions, permissions, and the admin's own N+1 risk

  • list_display: fields, __lookups, or callablesnever a raw ManyToManyField — that's a real N+1 risk
  • Inlines edit related objects togetherTabularInline (compact) vs StackedInline (fuller layout)
  • get_queryset() filters WHAT you seehas_*_permission() separately gates WHAT you can DO — both needed
  • list_display can hide an N+1list_select_related for FK/O2O; annotate() for a count/aggregate

1.ModelAdmin basics

ModelAdmin configures how ONE model appears and behaves in the admin. list_display picks the columns shown on the list page (fields, related-field lookups like "author__name", or a method taking the instance). list_filter adds a sidebar of filters; search_fields (icontains by default, with ^/=/@ prefixes for other lookup types) adds a search box. ordering sets the default sort; readonly_fields displays a field without letting it be edited; fieldsets groups the edit form's fields into labeled sections instead of one flat list.

Key features

  • list_display accepts a plain field name, a related field via __ (e.g. "author__name" — though this shows the value, not an editable link, unless separately configured), or a callable/method taking the model instance as its argument
  • ManyToManyField is NOT supported in list_display directly — showing it would require a separate query per row for every M2M value, which Django refuses to do implicitly
  • search_fields defaults to icontains — prefix a field with ^ for istartswith, = for iexact, or @ for PostgreSQL full-text search
  • readonly_fields displays a field's value without an editable input — works for both real model fields and callables/methods, useful for showing a computed or system-managed value
  • fieldsets is a list of (name, {"fields": [...], "classes": [...]}) tuples — a tuple of field names inside "fields" displays those fields on the SAME line instead of stacked

The core ModelAdmin list/search/form options

The core ModelAdmin list/search/form options
OptionControls
list_displaywhich columns appear on the changelist page
list_filterthe sidebar filter widgets
search_fieldswhich fields the search box queries, and how (icontains/istartswith/iexact/full-text)
orderingthe default sort order for the changelist
readonly_fieldsfields shown but not editable on the change form
fieldsetsgrouping/labeling/collapsing the change form's fields into sections
Each ModelAdmin attribute controls one part of the admin page

class ArticleAdmin(admin.ModelAdmin): list_display = ["title", "author", "status"] list_filter = ["status"] search_fields = ["title", "author__email"] ordering = ["-published_at"] readonly_fields = ["created_at"]

list_display

list_display — which columns appear on the changelist page

list_filter

list_filter — the sidebar filter widgets

search_fields

search_fields — which fields the search box queries, and how

ordering

ordering — the default sort order for the changelist

readonly_fields

readonly_fields — shown on the change form but not editable

  • Whole: class ArticleAdmin(admin.ModelAdmin): list_display = ["title", "author", "status"] list_filter = ["status"] search_fields = ["title", "author__email"] ordering = ["-published_at"] readonly_fields = ["created_at"]
  • list_display — list_display: which columns appear on the changelist page
  • list_filter — list_filter: the sidebar filter widgets
  • search_fields — search_fields: which fields the search box queries, and how
  • ordering — ordering: the default sort order for the changelist
  • readonly_fields — readonly_fields: shown on the change form but not editable

Command

python
@admin.register(Model)
class ModelAdminClass(admin.ModelAdmin):
    list_display = [...]
    fieldsets = [(None, {"fields": [...]}), ("Advanced", {"classes": ["collapse"], "fields": [...]})]

list_display accepts fields, related-field lookups (__), or callables — never a ManyToManyField directly (a real N+1 risk Django refuses to do implicitly). search_fields defaults to icontains; ^/=/@ prefixes switch to istartswith/iexact/full-text. fieldsets groups the edit form into labeled, optionally-collapsed sections.

2.Inlines, custom actions, and permissions

An inline (TabularInline for a compact table, StackedInline for a fuller per-object layout) lets related objects be edited on the SAME page as their parent — Comments on an Article's own edit page, not a separate list. A custom admin action (a method decorated with @admin.action, listed in actions) runs against a bulk-selected QuerySet from the changelist page — "mark selected as published," not one row at a time. The has_add_permission()/has_change_permission()/has_delete_permission()/has_view_permission() methods let admin access be restricted PER-OBJECT, not just per-model, based on request.user.

Key features

  • TabularInline shows related objects as compact table rows; StackedInline shows each as its own fuller, stacked fieldset — same underlying mechanism, different layout
  • extra controls how many blank forms for NEW related objects appear by default; min_num/max_num bound how many related objects are required/allowed
  • A custom action is a method taking (self, request, queryset) — decorated with @admin.action(description="...") — listed in the actions attribute, appearing in the changelist's bulk-action dropdown
  • has_change_permission(self, request, obj=None) is called with obj=None for the general "can this user access the change list at all" check, and with a real obj for a specific object — this is the hook for object-level (not just model-level) permission logic
  • get_queryset(self, request) can ALSO be overridden to filter what a given user sees at all (e.g. qs.filter(author=request.user)) — a different, complementary mechanism from has_*_permission(), which controls what actions are allowed on what a user can already see

Inline vs custom action vs permission override

Inline vs custom action vs permission override
MechanismSolves
TabularInline / StackedInlineediting related objects on the same page as their parent
A custom actiona bulk operation applied to many selected rows at once
has_*_permission() overrideobject-level access rules Django's model-level permission system can't express on its own
Two separate gates: what a user sees vs. what they can do

get_queryset()

filter(author=request.user)

controls the changelist

has_change_permission()

obj.author == request.user

checked even via a direct URL

  • get_queryset()
    • filter(author=request.user) — controls the changelist
  • has_change_permission()
    • obj.author == request.user — checked even via a direct URL

Command

python
class MyInline(admin.TabularInline):
    model = RelatedModel
    extra = 1

@admin.action(description="...")
def my_action(self, request, queryset): ...

Inlines (TabularInline/StackedInline) edit related objects on the parent's own page. A custom action (@admin.action, in actions) runs against a bulk-selected QuerySet, not one row at a time. get_queryset() filters what a user SEES; has_*_permission() separately gates what they can DO, including via a direct URL — both are needed for real object-level access control, neither alone is sufficient.

3.Admin performance and query optimization

A list_display method that reaches through a relationship (obj.author.name) triggers ONE query PER ROW on the changelist page — the exact N+1 pattern from earlier in this topic, just triggered by the admin's own row-rendering loop instead of an explicit Python for loop. list_select_related fixes this for a ForeignKey/OneToOne shown this way (a plain JOIN, same as select_related() elsewhere). For a computed value needing a ManyToMany count or aggregate, annotating the value in an overridden get_queryset() is the fix — computed once, in the query itself, not per-row in Python.

Key features

  • A list_display method/callable reaching through a relationship (obj.author.name, obj.category.title) triggers one query per row unless the relationship is already loaded — the exact same N+1 pattern as anywhere else in Django
  • list_select_related = ["author", "category"] (or True for all FK/O2O relations) applies select_related() to the admin's own list queryset — the direct fix for a ForeignKey/OneToOne shown via list_display
  • get_list_select_related(self, request) is the dynamic/conditional version, computed per-request rather than as a fixed class attribute
  • For a computed value needing a COUNT/SUM/aggregate (which select_related() cannot provide — it only works for single-valued FK/O2O), override get_queryset() to .annotate(...) the value once, in the query itself
  • @admin.display(ordering="annotated_field_name") lets an admin-sortable column be based on an annotated value, not just a real model field

Which fix, for which list_display shape

Which fix, for which list_display shape
list_display showsFix
A ForeignKey/OneToOne's related field (obj.author.name)list_select_related
A count/aggregate over a reverse FK or M2M (obj.comments.count())annotate() inside an overridden get_queryset()
Which fix, for which list_display shape

list_select_related

  • Fixes a ForeignKey/OneToOne shown via list_display
  • obj.author.name — single related row
  • Exactly select_related(), applied to the admin queryset

annotate() in get_queryset()

  • Fixes a count/aggregate over a reverse FK or M2M
  • obj.comments.count() — a "many" relationship
  • Computed once, in the query itself
  • list_select_related
    • Fixes a ForeignKey/OneToOne shown via list_display
    • obj.author.name — single related row
    • Exactly select_related(), applied to the admin queryset
  • annotate() in get_queryset()
    • Fixes a count/aggregate over a reverse FK or M2M
    • obj.comments.count() — a "many" relationship
    • Computed once, in the query itself

Command

python
class MyAdmin(admin.ModelAdmin):
    list_select_related = ["fk_field"]
    def get_queryset(self, request):
        return super().get_queryset(request).annotate(x=Count("related"))

A list_display method reaching through a relationship is the exact same N+1 pattern as anywhere else in Django, just triggered by the admin's own row-rendering loop. list_select_related fixes a single-valued FK/OneToOne (exactly select_related()'s shape); a count/aggregate over a reverse FK or M2M needs annotate() in an overridden get_queryset() instead (exactly the select_related()-vs-annotate() distinction from elsewhere in this topic).

Remember

  • list_display accepts fields, related lookups (__), or callables — a raw ManyToManyField is refused, since showing it would need a query per row.
  • A list_display method touching a relationship is N+1, exactly like anywhere else in Django — just invisible until row count makes it matter.
  • list_select_related fixes a single-valued FK/OneToOne; a count/aggregate over a "many" relationship needs annotate() in get_queryset() instead.
  • get_queryset() filters what a user sees; has_*_permission() separately gates what they can do, including via a direct URL — both are needed for real object-level access control.

Pro tip

  • A custom action receives the entire bulk-selected QuerySet — use .update() or another QuerySet-level operation, not a per-row loop with individual .save() calls.
  • The admin is an operational, staff-facing tool by design — stretching it to serve an external, non-staff audience usually fights its actual assumptions rather than benefiting from them.

Next up

  • Authentication
  • Authorization
  • Sessions and Cookies

Sources

  • ModelAdmin reference — docs.djangoproject.com/en/5.2/ref/contrib/admin/
  • Every claim checked against Django 5.2 LTS docs · checked 2026-08-24

Spotted a mistake on this page? Tell us.