Filter concepts by levelShowing all levels.

Django · Section 36

Django Admin

Level
advanced
Read
30 min
Concepts
3

ModelAdmin configures how one model appears in the admin — list_display's columns (fields, related lookups, or callables, never a raw ManyToManyField), list_filter/search_fields (icontains by default, with ^/=/@ prefixes for other lookup types), and fieldsets for organizing the edit form. Inlines (TabularInline/StackedInline) let related objects be edited on the same page as their parent; custom actions run against an entire bulk-selected QuerySet at once; has_*_permission() methods express object-level access rules Django's model-level permission system can't, complementing (not replacing) a get_queryset() filter — the roadmap's own explicit framing: the admin is an operational, staff-facing interface, not necessarily a replacement for a real business UI. A list_display method reaching through a relationship triggers the exact same N+1 pattern as anywhere else in Django, just via the admin's own row-rendering loop — list_select_related fixes a single-valued FK/OneToOne, and annotate() inside an overridden get_queryset() fixes a count/aggregate over a "many" relationship.

This section

What is true here

  1. list_display accepts fields, related-field lookups (__), or callables — never a raw ManyToManyField, since displaying it would require a query per row.
  2. search_fields defaults to icontains; ^/=/@ prefixes switch to istartswith/iexact/PostgreSQL full-text search respectively.
  3. Inlines (TabularInline/StackedInline) edit related objects on the parent's own page; a custom action runs against the entire bulk-selected QuerySet, not row by row.
  4. get_queryset() filtering controls what a user sees; has_*_permission() methods separately gate what they can do, including via a direct URL bypassing the filtered list — both are needed together for real object-level access control.
  5. A list_display method touching a relationship is the exact same N+1 pattern documented elsewhere in this topic — list_select_related fixes a single-valued FK/OneToOne; a count/aggregate over a reverse FK or M2M needs annotate() in get_queryset() instead.

What you will be able to do

  • Configure list_display/list_filter/search_fields/fieldsets for a readable, usable admin
  • Use inlines and custom bulk actions correctly
  • Implement real object-level access control combining get_queryset() and has_*_permission()
  • Diagnose and fix an admin-specific N+1, choosing the right tool for the relationship shape

ModelAdmin basics

The list page, search, filtering, and organizing the edit form.

ModelAdmin basics

coreintermediate

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.

Think of it as

ModelAdmin is a whole separate configuration OBJECT sitting between a model and the admin's generated pages — none of these attributes touch the model itself, they only describe how the admin should present and let staff interact with it. list_display is specifically about the LIST page's columns; it accepts three different kinds of things (a field name, a dunder-traversal to a related field, or a callable/method) precisely because "what should this column show" is sometimes a raw value, sometimes something computed. fieldsets exists because a flat form listing every field, in whatever order the model happens to declare them, is often not how a human actually wants to fill the form out — grouping related fields together, with some sections collapsed by default (via classes=["collapse"]), is purely a presentation decision layered on top of the same underlying fields.

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

What we're doing: Configure a readable admin for Article — searchable, filterable, with a computed status column and a collapsed "advanced" section on the edit form.

articles/admin.pypython
@admin.register(Article)
class ArticleAdmin(admin.ModelAdmin):
    list_display = ["title", "author", "status"]
    list_filter = ["status"]
    search_fields = ["title", "^slug"]

    fieldsets = [
        (None, {"fields": ["title", "slug", "author"]}),
        ("Advanced", {"classes": ["collapse"], "fields": ["meta_description", "og_image"]}),
    ]
5
"^slug" searches slug with istartswith, not the default icontains — appropriate for a slug, where a prefix match is usually what a staff user actually wants.
10
"Advanced" fieldset uses classes=["collapse"] — those fields are hidden behind a clickable header by default, keeping the common edit path focused on the fields most people actually need.

Why this works: A flat list of every Article field (title, slug, author, meta_description, og_image, ...) would bury the fields editors touch daily among ones they rarely need — fieldsets with a collapsed "Advanced" section keeps the common case fast while still making the less-common fields reachable.

Adding a ManyToManyField directly to list_display, expecting it to just work like a ForeignKey

Wrong

python
class ArticleAdmin(admin.ModelAdmin):
    list_display = ["title", "tags"]   # tags is a ManyToManyField — not supported

Better

python
class ArticleAdmin(admin.ModelAdmin):
    list_display = ["title", "tag_list"]

    def tag_list(self, obj):
        return ", ".join(t.name for t in obj.tags.all())

What you see: django.core.exceptions.ImproperlyConfigured (or a similar validation error) raised when Django's admin system checks run, specifically flagging the ManyToManyField in list_display.

Why: A ForeignKey/OneToOneField has exactly one related object, so its __str__() is a cheap, single value to show — a ManyToManyField could have any number of related objects, and showing them all would mean a SEPARATE query per row on the changelist page (a real N+1 risk), which Django refuses to do implicitly. A method that explicitly joins the related objects into a display string (accepting the query cost deliberately) is the documented workaround.

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

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

Together

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

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

See also: inlines actions and permissions · admin performance and query optimization · retrieval methods

Advertisement

Inlines, custom actions, and permissions

Editing related objects together, bulk operations, and object-level access control.

Inlines, custom actions, and permissions

coreadvanced

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.

Think of it as

An inline exists because some relationships are naturally edited TOGETHER — an Order and its OrderItems, an Article and its Comments — and forcing a staff user to save the parent, then navigate to a separate list to add each related object, is real friction for something conceptually "one thing." A custom action exists because the changelist's default behavior (view/edit one row) doesn't cover "do X to all 40 selected rows at once" — actions receive the entire selected QuerySet, so a bulk operation can use .update() or another QuerySet-level operation instead of looping and saving row by row. The has_*_permission() methods matter because Django's built-in permission SYSTEM only knows model-level permissions (can this user change ANY Article) — overriding these methods is how "can this user change only THEIR OWN articles" (an object-level rule) gets expressed, since Django's default permission system has no native concept of ownership.

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

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

What we're doing: Restrict an admin so non-superuser staff can only edit Articles they themselves authored, combining a get_queryset() filter with an object-level permission check.

articles/admin.pypython
class ArticleAdmin(admin.ModelAdmin):
    def get_queryset(self, request):
        qs = super().get_queryset(request)
        if request.user.is_superuser:
            return qs
        return qs.filter(author=request.user)

    def has_change_permission(self, request, obj=None):
        if obj is not None and not request.user.is_superuser:
            return obj.author == request.user
        return True
3
get_queryset() controls what a non-superuser even SEES in the changelist — their own articles only.
8
has_change_permission() is a SEPARATE check — even if get_queryset() were bypassed some other way (a direct URL to an object's change page), this still blocks editing an article that isn't the user's own.

Why this works: The two overrides are complementary, not redundant — get_queryset() controls what shows up in lists/searches, while has_change_permission() is checked specifically when someone tries to actually access a change form, including via a direct URL that bypasses the filtered list entirely; relying on only one of the two leaves a real gap.

Filtering get_queryset() for object-level access, but forgetting has_change_permission() still needs its own check

Wrong

python
class ArticleAdmin(admin.ModelAdmin):
    def get_queryset(self, request):
        return super().get_queryset(request).filter(author=request.user)
    # has_change_permission() left at its default — always returns True

Better

python
class ArticleAdmin(admin.ModelAdmin):
    def get_queryset(self, request):
        return super().get_queryset(request).filter(author=request.user)

    def has_change_permission(self, request, obj=None):
        if obj is not None:
            return obj.author == request.user
        return True

What you see: A user who knows (or guesses) another author's article's admin URL directly can still open and edit it, even though that article never appears in THEIR filtered changelist — the filtering only hid it from the list view, it never actually blocked direct access.

Why: get_queryset() filtering only affects what QUERIES through the admin return (the changelist, search results) — it does nothing to stop a direct URL visit to a specific object's change page, which goes through has_change_permission() instead. Relying on the list filter alone is "security through obscurity" (hidden, not actually blocked) rather than a real access control.

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

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

Together

python
class CommentInline(admin.TabularInline):
    model = Comment
    extra = 1

@admin.register(Article)
class ArticleAdmin(admin.ModelAdmin):
    inlines = [CommentInline]
    actions = ["make_published"]

    @admin.action(description="Mark selected articles as published")
    def make_published(self, request, queryset):
        updated = queryset.update(status="published")
        self.message_user(request, f"{updated} articles published.")

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

See also: modeladmin basics · admin performance and query optimization · instance methods and domain logic

Advertisement

Admin performance and query optimization

The N+1 risk specific to list_display, and its two fixes.

Admin performance and query optimization

coreadvanced

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.

Think of it as

The admin's changelist page is, under the hood, exactly the same shape of code as any other Django view rendering a list of objects — it builds a QuerySet, then loops over the results rendering each row, and list_display methods run once PER ROW in that loop. A method like def author_name(self, obj): return obj.author.name looks completely innocent, but is the identical hidden-N+1 pattern documented earlier in this topic (property-driven/method-driven N+1) — the admin gives no visual hint that it's happening, since the slowdown is invisible until the changelist has enough rows for it to matter. list_select_related is precisely select_related() applied to the admin's own queryset-building step, and get_queryset() annotation is precisely the same values()+annotate() technique from earlier — both existing tools, just applied specifically to where the admin builds its own list query.

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

What we're doing: Fix an admin changelist showing both a related field and a computed count, using the two different tools each shape actually needs.

articles/admin.pypython
@admin.register(Article)
class ArticleAdmin(admin.ModelAdmin):
    list_display = ["title", "author_name", "comment_count"]
    list_select_related = ["author"]

    def get_queryset(self, request):
        qs = super().get_queryset(request)
        return qs.annotate(comment_count=Count("comments"))

    def author_name(self, obj):
        return obj.author.name

    @admin.display(ordering="comment_count")
    def comment_count(self, obj):
        return obj.comment_count
3
list_select_related fixes author_name's N+1 — a single-valued ForeignKey, exactly the select_related() shape.
6
get_queryset() overridden to annotate comment_count — a reverse-FK COUNT, which select_related() has no way to express, so this is the OTHER tool needed alongside it.

Why this works: A changelist showing 50 articles would otherwise issue 1 (base query) + 50 (one per author lookup) + 50 (one per comment count) = 101 queries — list_select_related and the annotate() together bring that down to exactly 1 query total, since both the author JOIN and the comment count are computed as part of that single query.

Reaching for list_select_related to fix a count/aggregate column, when it only works for single-valued relationships

Wrong

python
class ArticleAdmin(admin.ModelAdmin):
    list_display = ["title", "comment_count"]
    list_select_related = ["comments"]   # WRONG — comments is a reverse FK / "many" relationship

    def comment_count(self, obj):
        return obj.comments.count()   # still N+1 — list_select_related did nothing here

Better

python
class ArticleAdmin(admin.ModelAdmin):
    list_display = ["title", "comment_count"]

    def get_queryset(self, request):
        return super().get_queryset(request).annotate(comment_count=Count("comments"))

    @admin.display(ordering="comment_count")
    def comment_count(self, obj):
        return obj.comment_count

What you see: Adding list_select_related does not fix the query count at all — the changelist still issues one query per row for comment_count, exactly as if list_select_related had never been added.

Why: list_select_related is precisely select_related() — it only ever works for forward FK/OneToOne relationships (a single related row), never a reverse FK or ManyToMany, which is the "many" shape select_related() can never handle anywhere in Django, not just in the admin. A count/aggregate over a "many" relationship needs annotate() in get_queryset() instead, the same select_related()-vs-prefetch_related()-shaped distinction from earlier in this topic, applied here to admin-specific tooling.

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

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()

Together

python
class ArticleAdmin(admin.ModelAdmin):
    list_display = ["title", "author_name", "comment_count"]
    list_select_related = ["author"]   # fixes author_name's N+1

    def author_name(self, obj):
        return obj.author.name   # already loaded — no extra query

    def get_queryset(self, request):
        return super().get_queryset(request).annotate(comment_count=Count("comments"))

    @admin.display(ordering="comment_count")
    def comment_count(self, obj):
        return obj.comment_count   # already computed by the query

Remember: 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).

See also: modeladmin basics · hidden n plus 1 sources · select related

Advertisement