Admin Site

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

The reference material consulted while preparing these pages is listed in the section bibliography.

This page is written against Django 6.1 (6.1.1, released 2026-09-02; 6.1.2 is scheduled for 2026-10-06), Python 3.12-3.14 and PostgreSQL 15 or newer, all checked on 2026-10-03; where the 5.2 LTS (5.2.17) behaves differently the text says so. Every example on this page was run on that stack in a scratch project: manage.py check, 33 Django TestCase tests through the test client, and curl against runserver and uvicorn. They all ran on SQLite because no PostgreSQL server was available; nothing on this page depends on a PostgreSQL feature, and the text says where a production database changes the advice (full-text search, large tables). The admin is the part of Django that turns your models into a ready-made back office: a change list with search, filters and bulk actions, a change form with validation, an audit trail, and a permission system. This page covers how it is wired in, how to register models, every ModelAdmin option and hook that matters, how the display and action decorators work, inlines, custom filters, admin permissions, and what to watch out for. It builds the staff side of the Bookshelf application (the catalog app developed across this section). Customising the site itself (a custom AdminSite, extra views, template overrides, JavaScript, securing the URL) is the subject of Admin Customization.

In a hurry: what goes where

Need Rule of thumb Section

Put a model in the admin

@admin.register(Model) on a ModelAdmin subclass in the app’s admin.py, or admin.site.register(Model) for the defaults.

Registering models

Choose the change-list columns

list_display: model fields, related__field lookups (5.1), callables and methods decorated with @admin.display.

The change list: list_display and its five kinds of value

Filter and search

list_filter (field names, SimpleListFilter subclasses, (field, FieldListFilter) pairs), search_fields, date_hierarchy.

Filters, facets, search and date drill-down

Lay out the form

fields or fieldsets, readonly_fields, prepopulated_fields, autocomplete_fields, raw_id_fields.

The change form: fields, layout and widgets

Edit related rows on the parent page

TabularInline or StackedInline in inlines.

Inlines

Act on many rows

@admin.action functions or methods listed in actions.

Actions

Decide who may do what

is_staff to enter; add/change/delete/view model permissions; has_*_permission() for rules; get_queryset() for row scoping.

Permissions in the admin

Keep the list fast

list_select_related, show_facets, show_full_result_count, indexed search_fields, a custom paginator.

Pitfalls

Change behaviour for the whole site

Subclass AdminSite; see the Admin Customization page.

Where to go next

Where the admin sits in the request and response cycle

The admin is an ordinary Django app (django.contrib.admin) that exposes one URLconf, admin.site.urls, which you mount wherever you like (the project template uses admin/). There is no special server: a request to /admin/…​ travels through the same middleware stack as any other URL (see Middleware and The Request/Response Cycle), is matched by the URL resolver, and reaches a view that happens to be a method of AdminSite or ModelAdmin.

sequenceDiagram participant B as Browser participant M as Middleware (session, auth, messages, CSRF) participant S as AdminSite.admin_view participant A as ModelAdmin view participant F as ModelForm and formsets participant D as Database B->>M: GET or POST /admin/catalog/book/ M->>S: request with request.user and messages S->>S: has_permission? (active and staff) alt not staff S-->>B: 302 to /admin/login/?next=... else staff S->>A: changelist_view / add_view / change_view A->>A: has_*_permission(request, obj) A->>F: build form, validate, save_model, save_formset F->>D: SELECT, INSERT, UPDATE, DELETE A-->>B: TemplateResponse, or redirect with a flash message end

Three facts follow from that picture and explain most admin behaviour.

  • Every admin view is wrapped by AdminSite.admin_view(). The wrapper checks AdminSite.has_permission() (the user must be active and is_staff), sends anonymous users to the admin login page with a next parameter, applies never_cache and csrf_protect. Views you add yourself must be wrapped the same way (covered in Admin Customization).

  • The admin needs three middleware classes and four context processors. SessionMiddleware, AuthenticationMiddleware and MessageMiddleware must be in MIDDLEWARE; the request, auth and messages context processors must be configured on a DjangoTemplates backend. System checks admin.E401 to admin.E410 verify all of this, so manage.py check tells you what is missing.

  • Authorisation happens inside the admin view, per request. After admin_view lets a staff user in, each ModelAdmin view asks has_view_permission(), has_add_permission(), has_change_permission() or has_delete_permission(), and answers 403 when the answer is no. This is where the rules of Permissions in the admin plug in.

Enabling the admin

django-admin startproject enables the admin: django.contrib.admin and its dependencies are in INSTALLED_APPS, the middleware and context processors are configured, and config/urls.py mounts it. For a project that was not generated from the template, the requirements are exactly these.

The admin’s requirements (config/settings.py)
INSTALLED_APPS = [
    "django.contrib.admin",
    "django.contrib.auth",
    "django.contrib.contenttypes",
    "django.contrib.sessions",
    "django.contrib.messages",
    "django.contrib.staticfiles",
    "catalog",
]

MIDDLEWARE = [
    "django.middleware.security.SecurityMiddleware",
    "django.contrib.sessions.middleware.SessionMiddleware",
    "django.middleware.common.CommonMiddleware",
    "django.middleware.csrf.CsrfViewMiddleware",
    "django.contrib.auth.middleware.AuthenticationMiddleware",
    "django.contrib.messages.middleware.MessageMiddleware",
    "django.middleware.clickjacking.XFrameOptionsMiddleware",
]

TEMPLATES = [
    {
        "BACKEND": "django.template.backends.django.DjangoTemplates",
        "DIRS": [],
        "APP_DIRS": True,
        "OPTIONS": {
            "context_processors": [
                "django.template.context_processors.request",
                "django.contrib.auth.context_processors.auth",
                "django.contrib.messages.context_processors.messages",
            ],
        },
    },
]
Mounting the admin and the public Bookshelf URLs (config/urls.py)
from django.contrib import admin
from django.urls import include, path

urlpatterns = [
    path("admin/", admin.site.urls),
    path("", include("catalog.urls")),
]

Create the first user with createsuperuser. Logging in to the admin requires is_staff=True; a superuser has it and every permission. In scripts and CI the password comes from the environment instead of a prompt.

Creating a superuser without a prompt
$ python manage.py migrate
$ DJANGO_SUPERUSER_PASSWORD=change-me python manage.py createsuperuser \
    --noinput --username admin --email admin@example.com
Superuser created successfully.

Source: createsuperuser

A first smoke test with curl against runserver (here on port 8737) shows the permission wrapper at work: anonymous requests are redirected to the login page, and the login page itself answers 200.

Anonymous access is redirected (checked with runserver)
$ curl -s -o /dev/null -w "%{http_code} %{redirect_url}\n" http://127.0.0.1:8737/admin/catalog/book/
302 http://127.0.0.1:8737/admin/login/?next=/admin/catalog/book/
$ curl -s -o /dev/null -w "%{http_code}\n" "http://127.0.0.1:8737/admin/login/"
200

Never leave /admin/ as the only protection of a production back office: the placement of the URL, HTTPS, throttling and two-factor authentication are covered in Admin Customization and Security.

The Bookshelf models

The examples use the catalogue of the running scenario: authors, books, genres and reviews. Two details matter for the admin. Book.Meta.permissions declares a custom publish_book permission that later gates publishing and withdrawing, and MyBook is a proxy model, a second admin page over the same table with its own permissions.

The Bookshelf models (catalog/models.py)
from django.conf import settings
from django.core.validators import MaxValueValidator, MinValueValidator
from django.db import models
from django.urls import reverse


class Genre(models.Model):
    name = models.CharField(max_length=60, unique=True)

    class Meta:
        ordering = ["name"]

    def __str__(self):
        return self.name


class Author(models.Model):
    name = models.CharField(max_length=200)
    slug = models.SlugField(unique=True)
    country = models.CharField(max_length=2, blank=True)

    class Meta:
        ordering = ["name"]

    def __str__(self):
        return self.name


class Book(models.Model):
    class Status(models.TextChoices):
        DRAFT = "draft", "Draft"
        PUBLISHED = "published", "Published"
        WITHDRAWN = "withdrawn", "Withdrawn"

    title = models.CharField(max_length=200)
    subtitle = models.CharField(max_length=200, blank=True)
    slug = models.SlugField(unique=True)
    isbn = models.CharField(max_length=13, unique=True)
    author = models.ForeignKey(Author, on_delete=models.PROTECT, related_name="books")
    genres = models.ManyToManyField(Genre, blank=True, related_name="books")
    price = models.DecimalField(max_digits=6, decimal_places=2)
    published = models.DateField()
    status = models.CharField(
        max_length=10, choices=Status, default=Status.DRAFT
    )
    # Set by the server (the admin), never typed by an editor:
    added_by = models.ForeignKey(
        settings.AUTH_USER_MODEL,
        null=True,
        blank=True,
        on_delete=models.SET_NULL,
        related_name="+",
    )
    created_at = models.DateTimeField(auto_now_add=True)

    class Meta:
        ordering = ["title"]
        permissions = [("publish_book", "Can publish or withdraw books")]

    def __str__(self):
        return self.title

    def get_absolute_url(self):
        return reverse("catalog:book-detail", kwargs={"slug": self.slug})


class Review(models.Model):
    book = models.ForeignKey(Book, on_delete=models.CASCADE, related_name="reviews")
    reviewer = models.ForeignKey(
        settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="reviews"
    )
    rating = models.PositiveSmallIntegerField(
        validators=[MinValueValidator(1), MaxValueValidator(5)]
    )
    body = models.TextField(blank=True)

    class Meta:
        ordering = ["id"]
        constraints = [
            models.UniqueConstraint(
                fields=["book", "reviewer"], name="one_review_per_reader_and_book"
            )
        ]

    def __str__(self):
        return f"{self.reviewer} on {self.book}: {self.rating}/5"


class MyBook(Book):
    """Proxy model: same table as Book, its own admin page and permissions."""

    class Meta:
        proxy = True
        verbose_name = "my book"
        verbose_name_plural = "my books"

Source: Models and Meta.permissions

The relevant model-side hooks for the admin are __str__ (the label used in lists, selects, breadcrumbs and the delete confirmation), Meta.ordering, get_absolute_url() (which switches on the "View on site" link) and verbose_name/verbose_name_plural (the names on the index page). Models are covered in Models & Fields.

Registering models

A model appears in the admin only after it is registered. Django imports the admin module of every installed app at start-up (autodiscover(), called by AdminConfig.ready()), so the convention is a catalog/admin.py that registers the app’s models.

The three ways to register

Registering with the defaults, with a class, and with a decorator
from django.contrib import admin

from .models import Author, Book, Genre

# 1. Defaults: no ModelAdmin needed.
admin.site.register(Genre)

# 2. A ModelAdmin class passed explicitly.
class AuthorAdmin(admin.ModelAdmin):
    search_fields = ["name"]

admin.site.register(Author, AuthorAdmin)

# 3. The decorator, the usual form. Several models may share one class.
@admin.register(Book)
class BookAdmin(admin.ModelAdmin):
    list_display = ["title", "author"]

AdminSite.register(model_or_iterable, admin_class=None, **options) also accepts keyword options that become attributes of a generated admin class, so admin.site.register(Genre, search_fields=["name"]) is a one-line admin. admin.register() takes one or more models and a site= keyword for a custom site. It cannot be used when the class needs to refer to itself in __init__ (use plain super().__init__() and it works again).

Registry errors and discovery

  • Registering a model twice raises django.contrib.admin.exceptions.AlreadyRegistered; registering an abstract model raises ImproperlyConfigured.

  • admin.site.unregister(Model) removes a registration (raising NotRegistered when there is none). This is how you replace the admin of a model owned by another app, such as User (see Custom User Model & Backends).

  • admin.site.get_model_admin(Model) returns the registered admin instance (added in 5.0).

  • A project that uses its own AdminSite instance and imports every admin.py itself should list django.contrib.admin.apps.SimpleAdminConfig in INSTALLED_APPS instead of django.contrib.admin, which turns auto-discovery off.

Looking at the registry in the shell
$ python manage.py shell -c "
from django.contrib import admin
from catalog.models import Book
print(type(admin.site.get_model_admin(Book)).__name__)
print(sorted(m.__name__ for m in admin.site._registry if m._meta.app_label == 'catalog'))
"
BookAdmin
['Author', 'Book', 'Genre', 'MyBook', 'Review']

Two names of the admin are worth remembering because they appear in tests and templates. The URL names are admin:<app_label>_<model_name>_<view> (changelist, add, change, delete, history), for example admin:catalog_book_changelist, and the model permissions are <app_label>.<action>_<model_name>, for example catalog.change_book.

ModelAdmin: the options in groups

ModelAdmin is the representation of one model in the admin. Everything is a class attribute (a static choice) with a get_*() method twin (a choice that depends on the request or the object). The next sections walk through the options in the order a page is built, and the complete Bookshelf admin.py is assembled piece by piece, in file order. The file starts with its imports.

Imports of the Bookshelf admin (catalog/admin.py)
import csv

from django.contrib import admin, messages
from django.contrib.admin import ActionLocation
from django.contrib.auth import get_permission_codename
from django.db.models import Avg, Count
from django.http import HttpResponse
from django.utils import timezone
from django.utils.html import format_html
from django.utils.translation import ngettext

from .models import Author, Book, Genre, MyBook, Review

The change list: list_display and its five kinds of value

list_display sets the columns. Without it the list shows one column, the object’s __str__. Each element can be:

  1. The name of a model field (a ForeignKey shows the related object’s __str__; a BooleanField shows a yes/no/unknown icon).

  2. A related field with the double-underscore notation, such as author__country (Django 5.1; before that the only way was a callable).

  3. A callable that accepts the model instance.

  4. A string naming a ModelAdmin method that accepts the instance.

  5. A string naming a model attribute or method with no required argument.

Django resolves an element in that order (a model field wins over a ModelAdmin attribute of the same name). ManyToManyField is not supported directly because it would run one query per row; write a method that does the work once (for example from an annotation) instead.

The decorator @admin.display (Django 3.2) configures how a callable behaves as a column or a read-only field.

Argument Effect

description

The column header, or the label of a read-only field. Defaults to the name with underscores replaced by spaces.

ordering

The field (or lookup, or query expression, or "-field" for descending) the column sorts by. Without it a callable column is not sortable.

boolean

True shows the yes/no/unknown icon for a callable that returns True, False or None. Since 5.0 it also works on properties, and since 6.1 the icon is used for boolean fields on related models.

empty_value

What to show when the value is None, an empty string or an empty iterable; overrides ModelAdmin.empty_value_display for that column.

Output of a callable is HTML-escaped. To emit markup, build it with format_html() (which escapes the arguments) and never with string formatting plus mark_safe(): the values come from user input. A property can be a column too, but @property must be the outer decorator and @admin.display the inner one.

Source: list_display

The Bookshelf BookAdmin combines all of this. First the change-list options.

The change list of BookAdmin (catalog/admin.py)
@admin.register(Book)
class BookAdmin(admin.ModelAdmin):
    # --- change list
    list_display = [
        "title",
        "author",
        "author__country",
        "price",
        "published",
        "status",
        "is_recent",
        "average_rating",
    ]
    list_display_links = ["title"]
    list_editable = ["price"]
    list_filter = [
        "status",
        "genres",
        ("author", admin.RelatedOnlyFieldListFilter),
        "published",
        RatingBandFilter,
        ("subtitle", admin.EmptyFieldListFilter),
    ]
    show_facets = admin.ShowFacets.ALWAYS
    search_fields = ["title", "isbn", "author__name", "price__exact"]
    search_help_text = "Title, ISBN, author name or exact price"
    date_hierarchy = "published"
    list_select_related = ["author"]
    list_per_page = 25
    show_full_result_count = False
    save_on_top = True
    empty_value_display = "-"

And the callables the options refer to: is_recent (a boolean icon sortable by published), average_rating (an annotated value, sortable by the annotation, with its own empty text) and review_summary (markup built with format_html()). The annotation comes from get_queryset().

Display callables and the annotated queryset (BookAdmin, continued)
    # --- queryset
    def get_queryset(self, request):
        return super().get_queryset(request).annotate(avg_rating=Avg("reviews__rating"))

    # --- display callables
    @admin.display(boolean=True, description="Recent", ordering="published")
    def is_recent(self, obj):
        return obj.published.year >= timezone.localdate().year - 1

    @admin.display(description="Rating", ordering="avg_rating", empty_value="no reviews")
    def average_rating(self, obj):
        return None if obj.avg_rating is None else round(obj.avg_rating, 1)

    @admin.display(description="Reviews")
    def review_summary(self, obj):
        if obj.pk is None:
            return "-"
        count = obj.reviews.count()
        return format_html("<strong>{}</strong> review(s)", count)

Related options for the list:

  • list_display_links chooses which columns link to the change form (default: the first). None gives a grid without links (used by a read-only report; see ReviewWidgetAdmin below).

  • list_editable turns columns into form widgets so many rows can be edited and saved at once. A field must be in list_display, must not be in list_display_links, and the first column cannot be editable unless the links are moved. Breaking these rules fails the system check (admin.E122 to admin.E124).

  • ordering (and get_ordering()) sets the default order and falls back to the model’s Meta.ordering. The change list always adds pk to the ordering when the chosen fields do not give a total order, which can be slow on a big table without a suitable index.

  • sortable_by (and get_sortable_by()) limits which columns can be sorted; an empty collection disables sorting.

  • list_per_page (default 100), list_max_show_all (default 200, the largest result for which "Show all" appears), paginator and get_paginator() control pagination. show_full_result_count (default True) controls the second number of "99 results (103 total)", which costs a COUNT(*) of the whole table.

  • empty_value_display replaces the default dash for empty values; AdminSite.empty_value_display does it site-wide.

  • preserve_filters (default True) keeps the active filters on the list after an add, change or delete.

  • actions_on_top (default True), actions_on_bottom (default False) and actions_selection_counter (default True) place and decorate the actions bar.

A column that follows a foreign key (author here) runs one extra query per row unless the related row is fetched with the list. list_select_related controls that: a list or tuple of field names is passed to select_related(), an empty tuple disables it, and get_list_select_related(request) makes it dynamic. Two changes matter on 6.1: when the option is False (the default) the change list now selects only the foreign keys named in list_display instead of all of them, and setting the option to True (or returning True from the method) is deprecated, so name the fields. In the scratch project with 30 books, the change list ran 11 queries both with the default and with list_select_related = ["author"], and 36 with list_select_related = [], which is the N+1 problem in one number. ModelAdmin ignores the option if the changelist queryset already called select_related().

Filters, facets, search and date drill-down

list_filter adds the right-hand sidebar. A plain field name gives the filter that matches the field type (BooleanField, CharField with choices, DateField, DateTimeField, IntegerField, ForeignKey, ManyToManyField), lookups across relations work, and a tuple (field, FilterClass) picks a filter explicitly. Custom list filters shows how to write your own. get_list_filter(request) makes the list dynamic.

Facets (Django 5.0) show the number of rows behind each filter choice, updating as filters are applied. show_facets takes a ShowFacets value: ALLOW (the default: counts appear when the user toggles them on, which adds _facets to the query string), ALWAYS (as in BookAdmin) and NEVER. Each filter costs extra queries, which matters on big tables: the 30-book change list ran 11 queries with ALLOW and 35 with ALWAYS.

search_fields adds the search box. Terms are split on spaces (a quoted phrase stays together); every term must match at least one field, and the default lookup is icontains. A field name can carry any lookup (isbn__exact, price__exact) and can follow relations (author__name). The old one-character prefixes still work: ^ for istartswith, = for iexact, @ for the search lookup. search_help_text (4.0) shows a hint below the box, and get_search_fields() and get_search_results() customise it. On 6.1 a term that is not valid for a field with an explicit lookup, such as hello for price__exact, is skipped for that field instead of raising an error; on 5.2 drop non-text lookups from search_fields or override get_search_results().

date_hierarchy names a DateField or DateTimeField (it may follow a relation, author__pub_date) and adds drill-down links above the list. With USE_TZ = True it relies on QuerySet.datetimes(), so the current time zone matters.

A test pins the search and filter behaviour of the Bookshelf (more tests of the same module appear with the actions and with the permissions).

Filters, search and the 6.1 term skipping (catalog/tests.py)
class RegistrationAndChangelistTests(AdminTestBase):
    def setUp(self):
        self.client.force_login(self.superuser)

    def test_search_and_invalid_decimal_term_is_skipped(self):
        url = reverse("admin:catalog_book_changelist")
        self.assertContains(self.client.get(url, {"q": "lovelace"}), "Engines")
        self.assertContains(self.client.get(url, {"q": "12.50"}), "Notes")
        self.assertEqual(self.client.get(url, {"q": "hello"}).status_code, 200)

    def test_filters(self):
        url = reverse("admin:catalog_book_changelist")
        r = self.client.get(url, {"rating_band": "top"})
        self.assertEqual([b.title for b in r.context["cl"].result_list], ["Engines"])
        r = self.client.get(url, {"rating_band": "none"})
        self.assertEqual([b.title for b in r.context["cl"].result_list], ["Notes"])
        r = self.client.get(url, {"subtitle__isempty": "0"})
        self.assertEqual([b.title for b in r.context["cl"].result_list], ["Engines"])
        r = self.client.get(url, {"status__exact": "published"})
        self.assertEqual(len(r.context["cl"].result_list), 1)

Source: Testing tools

Custom list filters

SimpleListFilter is the base class for a filter that is not a field. It needs a title (the sidebar heading) and a parameter_name (the query-string key), a lookups(request, model_admin) method that returns (value, label) pairs (the filter is hidden when lookups() returns nothing), and a queryset(request, queryset) method that applies self.value(). Both methods receive the request, so a filter can depend on the user, and lookups() receives the model_admin, so it can offer only choices that match existing rows.

A rating-band filter based on the annotated average (catalog/admin.py)
class RatingBandFilter(admin.SimpleListFilter):
    title = "rating"
    parameter_name = "rating_band"

    def _bands(self, queryset):
        # Relies on the avg_rating annotation added by BookAdmin.get_queryset().
        return {
            "top": ("4 or better", queryset.filter(avg_rating__gte=4)),
            "mixed": ("below 4", queryset.filter(avg_rating__lt=4)),
            "none": ("no reviews yet", queryset.filter(avg_rating__isnull=True)),
        }

    def lookups(self, request, model_admin):
        # Only offer the bands that match at least one visible book.
        queryset = model_admin.get_queryset(request)
        return [
            (key, label)
            for key, (label, rows) in self._bands(queryset).items()
            if rows.exists()
        ]

    def queryset(self, request, queryset):
        if self.value() in ("top", "mixed", "none"):
            return self._bands(queryset)[self.value()][1]
        return queryset

The filter above relies on the avg_rating annotation added by BookAdmin.get_queryset(). That coupling is deliberate (one aggregate, computed once), and it is a pitfall to know: the filter stops working if the annotation is removed.

The built-in field filters need to be opted into with a (field, FilterClass) pair. BookAdmin uses two of them:

  • admin.RelatedOnlyFieldListFilter limits a foreign-key filter to the related rows that are actually used (only authors who have a book, instead of every author).

  • admin.EmptyFieldListFilter filters on empty or null values (books with or without a subtitle).

Other FieldListFilter subclasses are BooleanFieldListFilter, ChoicesFieldListFilter, DateFieldListFilter, AllValuesFieldListFilter and RelatedFieldListFilter. Since 5.0 the related, choices and all-values filters accept multi-valued query parameters, and a custom FieldListFilter that filters with the __in lookup can set list_separator (4.1) and override expected_parameters(). A filter can set template to render itself differently, and a filter’s has_output() decides whether it shows. GenericForeignKey fields are not supported in list_filter.

The change form: fields, layout and widgets

When neither fields nor fieldsets is set, the form shows every editable non-AutoField field in model order, followed by the readonly_fields. Use:

  • fields for simple layouts: a subset or different order; wrap names in a tuple to put them on one line ([("price", "isbn")]). exclude removes fields from the generated form. When a ModelForm and the ModelAdmin both define exclude, the admin’s wins.

  • fieldsets for sections: a list of (title, options) pairs, where options has fields (required), classes (collapse for an initially collapsed section) and description. Fieldset descriptions are not HTML-escaped, so escape user-controlled text with django.utils.html.escape(). The wide class was removed in 6.1 because the new layout made it obsolete. TabularInline supports only the fields key of a fieldset.

  • readonly_fields for values that are shown but not edited: model fields, model methods and ModelAdmin methods (decorated with @admin.display). Read-only fields are excluded from the form, are added last when fields is not set, and are ignored when fields or fieldsets is set and does not list them.

  • prepopulated_fields maps a slug field to its sources and fills it with JavaScript while the object is new. It does not accept DateTimeField, ForeignKey, OneToOneField or ManyToManyField, and it never rewrites a saved slug, so URLs do not change.

  • autocomplete_fields replaces the <select> of a ForeignKey or ManyToManyField with a Select2 box that loads options as you type. The related model’s admin must define search_fields, and the user needs the view or change permission on the related model, otherwise the autocomplete URL answers 403. The related admin’s get_ordering() and get_paginator() shape the results (system checks admin.E039 and admin.E040 catch the missing pieces).

  • raw_id_fields shows a plain input for the primary key plus a lookup popup, the cheapest widget for a table with millions of rows. It needs the related model registered on the same site to show the lookup.

  • radio_fields renders a ForeignKey or a field with choices as radio buttons (admin.VERTICAL or admin.HORIZONTAL); filter_horizontal and filter_vertical give a many-to-many field the two-box chooser.

  • formfield_overrides maps a field class (not a string) to extra form-field arguments, such as a widget for every TextField. It cannot change the widget of a relation field that is in raw_id_fields, radio_fields or autocomplete_fields.

  • form swaps in your own ModelForm for custom validation. Omit Meta.model (the admin supplies it), or set Meta.fields = []; Meta.fields is ignored anyway because the admin has its own way to choose fields.

  • save_as replaces "Save and add another" with "Save as new"; save_as_continue=False sends the user to the list afterwards. save_on_top repeats the save buttons above the form.

  • view_on_site controls the "View on site" link: True (the default) uses get_absolute_url(), False hides it, and a callable that takes the object returns any URL.

  • delete_confirmation_max_display (new in 6.1) truncates the list on the delete-confirmation page, a display-only limit that is useful when deleting one object cascades to thousands.

  • The add_form_template, change_form_template, change_list_template, delete_confirmation_template, delete_selected_confirmation_template, object_history_template and popup_response_template attributes point at alternative templates; the techniques are in Admin Customization.

The change form of BookAdmin uses fieldsets with a collapsed audit section, read-only fields filled by the server, autocomplete_fields (so AuthorAdmin and GenreAdmin define search_fields) and an inline.

The change form of BookAdmin (BookAdmin, continued)
    # --- change form
    autocomplete_fields = ["author", "genres"]
    prepopulated_fields = {"slug": ["title"]}
    readonly_fields = ["added_by", "created_at", "review_summary"]
    fieldsets = [
        (None, {"fields": ["title", "subtitle", "slug", "author", "genres"]}),
        ("Commercial", {"fields": [("price", "isbn"), ("published", "status")]}),
        (
            "Audit",
            {
                "classes": ["collapse"],
                "description": "Filled in by the server.",
                "fields": ["added_by", "created_at", "review_summary"],
            },
        ),
    ]
    inlines = [ReviewInline]
    actions = ["publish", "withdraw", "export_csv"]

The smaller options above are shown together in a second module, catalog/admin_examples.py. Each class was registered on its own AdminSite in a test that requests the change list, the add form and the change form of every example, and that asserts what the options render.

Widgets, layout and save options (catalog/admin_examples.py)
"""Smaller option examples; each class is registered on its own AdminSite in tests."""

from django import forms
from django.contrib import admin, messages
from django.contrib.admin import ActionLocation
from django.core import serializers
from django.db import models
from django.http import HttpResponse

from .models import Author, Book, Review

# ----------------------------------------------------------- widgets, layout
class BookLayoutAdmin(admin.ModelAdmin):
    radio_fields = {"status": admin.VERTICAL}  # choices field as radio buttons
    filter_horizontal = ["genres"]  # two-box JavaScript chooser
    raw_id_fields = ["author"]  # primary-key input plus lookup popup
    save_as = True  # "Save as new" replaces "Save and add another"
    save_as_continue = False  # ...and returns to the change list
    actions_on_top = False
    actions_on_bottom = True
    actions_selection_counter = False
    preserve_filters = False  # forget list filters after saving
    list_max_show_all = 50
    sortable_by = ["title", "published"]  # the other columns are not sortable
    delete_confirmation_max_display = 5  # new in 6.1

    def view_on_site(self, obj):  # a callable instead of True/False
        return "https://books.example.com" + obj.get_absolute_url()


class ReviewAdminForm(forms.ModelForm):
    """A plain ModelForm without Meta.model: the admin supplies the model."""

    def clean_body(self):
        body = self.cleaned_data["body"]
        if self.cleaned_data.get("rating") == 1 and not body.strip():
            raise forms.ValidationError("Explain a one-star rating.")
        return body

    def clean(self):
        cleaned = super().clean()
        if cleaned.get("rating") == 1 and not cleaned.get("body", "").strip():
            self.add_error("body", "Explain a one-star rating.")
        return cleaned


class ReviewWidgetAdmin(admin.ModelAdmin):
    form = ReviewAdminForm
    formfield_overrides = {
        models.TextField: {"widget": forms.Textarea(attrs={"rows": 3, "cols": 60})},
    }
    list_display = ["book", "rating"]
    list_display_links = None  # a read-only grid: no row links at all
    empty_value_display = "(none)"

    def formfield_for_foreignkey(self, db_field, request, **kwargs):
        if db_field.name == "reviewer":
            from django.contrib.auth import get_user_model

            kwargs["queryset"] = get_user_model().objects.filter(is_active=True)
        return super().formfield_for_foreignkey(db_field, request, **kwargs)

The get_* hooks and the methods you override

Almost every option has a get_*() twin for per-request decisions: get_list_display(), get_list_display_links(), get_list_filter(), get_list_select_related(), get_search_fields(), get_sortable_by(), get_ordering(), get_fields(), get_fieldsets(), get_exclude(), get_readonly_fields(), get_prepopulated_fields(), get_autocomplete_fields(), get_inlines() and get_inline_instances(). The most-used methods and what they are for:

Method Use it to

get_queryset(request)

Add annotations or select_related(), or restrict which rows a user can see (Permissions in the admin).

save_model(request, obj, form, change)

Do something before or after saving the object, such as stamping added_by. It must save the object itself (call super()); it is not a veto.

delete_model(request, obj) and delete_queryset(request, queryset)

Run extra work when one object, or the "delete selected" action’s queryset, is deleted. Same rule: you must delete.

save_formset(request, form, formset, change)

Process inline objects, for example to stamp the user; save_related() runs after the parent and the formsets are saved.

get_form(request, obj=None, **kwargs) and get_formsets_with_inlines()

Swap the form class or choose the inlines per request or per object.

formfield_for_foreignkey(), formfield_for_manytomany() and formfield_for_choice_field()

Limit the choices of one field, for example to active users.

get_search_results(request, queryset, search_term)

Replace or extend the search; return (queryset, may_have_duplicates).

get_changeform_initial_data(request)

Provide initial values for the add form (the query string also does).

lookup_allowed(lookup, value, request)

Decide which ?field__lookup=value query parameters are accepted on the list; unknown lookups are rejected with a 400 (DisallowedModelAdminLookup) so users cannot probe relations.

message_user(request, message, level=…​)

Flash a message with the messages framework.

response_add(), response_change(), response_delete()

Change where the user lands after an add, change or delete.

get_urls()

Add views at /admin/<app>/<model>/<name>/ (see Admin Customization).

changelist_view(), add_view(), change_view(), delete_view(), history_view()

Wrap the whole view and pass extra_context; they return TemplateResponse objects you can modify.

A pitfall deserves its own mention: the get_*() hooks return the class attribute itself, not a copy. Appending to the result of super().get_readonly_fields() therefore mutates the class attribute and the list grows with every request. Always build a new list, as BookAdmin.get_readonly_fields() does below.

The second example module contains examples of the dynamic hooks.

Dynamic hooks (catalog/admin_examples.py)
# ------------------------------------------------------------ dynamic hooks
class BookDynamicAdmin(admin.ModelAdmin):
    search_fields = ["title"]
    list_display = ["title", "author", "price"]
    list_filter = ["status"]
    inlines = []

    def get_list_display(self, request):
        if request.user.has_perm("catalog.change_book"):
            return [*self.list_display, "isbn"]
        return self.list_display

    def get_list_filter(self, request):
        return [*self.list_filter, "genres"] if request.user.is_superuser else self.list_filter

    def get_ordering(self, request):
        return ["-published"] if request.user.is_superuser else ["title"]

    def get_changeform_initial_data(self, request):
        return {"status": Book.Status.DRAFT, "price": "9.99"}

    def get_search_results(self, request, queryset, search_term):
        queryset, may_have_duplicates = super().get_search_results(
            request, queryset, search_term
        )
        if search_term.isdigit():  # also find a book by primary key
            queryset |= self.model.objects.filter(pk=int(search_term))
        return queryset, may_have_duplicates

    def get_actions(self, request, action_location=ActionLocation.CHANGE_LIST):
        actions = super().get_actions(request, action_location=action_location)
        if not request.user.is_superuser:
            actions.pop("delete_selected", None)  # keys are names, values Action objects
        return actions

    def lookup_allowed(self, lookup, value, request):
        if lookup == "added_by__username":  # explicitly allow one extra lookup
            return True
        return super().lookup_allowed(lookup, value, request)


class ReviewInlineOnChangeAdmin(admin.ModelAdmin):
    """Show the inline only when editing, never on the add form."""

    class ReviewInline(admin.StackedInline):
        model = Review
        fk_name = "book"
        extra = 1
        max_num = 5
        min_num = 0
        can_delete = False
        show_change_link = True
        classes = ["collapse"]

    inlines = [ReviewInline]

    def get_inlines(self, request, obj):
        return self.inlines if obj is not None else []

Inlines

An inline edits rows of a related model on the parent’s page. TabularInline (a table, compact) and StackedInline (a block per row) differ only in the template. Both are InlineModelAdmin subclasses, so most ModelAdmin options (fields, fieldsets, readonly_fields, raw_id_fields, autocomplete_fields, prepopulated_fields, ordering, formfield_overrides, form, the formfield_for_* hooks and the permission methods) work in them.

Option Effect

model

Required: the model of the inline.

fk_name

The foreign key to the parent. Required only when the model has more than one foreign key to the same parent.

formset

A BaseInlineFormSet subclass for custom formset logic; inlines are model formsets.

form

A ModelForm for custom validation. Be careful with validation that depends on the parent: the parent may have failed validation.

extra, max_num, min_num

Extra empty forms (default 3), the maximum and the minimum number of forms. get_extra(), get_max_num() and get_min_num() make them dynamic. JavaScript adds an "Add another" link until max_num is reached.

can_delete

Whether rows can be deleted from the inline (default True).

show_change_link

Adds a link to the row’s own change form (default False).

classes, template

Extra CSS classes (collapse collapses the inline) and an alternative template.

verbose_name, verbose_name_plural

Override the model’s names; the plural defaults to the singular plus s.

has_add_permission(request, obj), has_change_permission(), has_delete_permission()

Per-inline permissions; obj is the parent object (or None when the parent is being added).

The author page edits an author’s books as a table, and the ReviewInline of the book page lets editors moderate (edit or delete) reviews but not write new ones.

A tabular inline and the parent that stamps its new rows (catalog/admin.py)
# --------------------------------------------------------------- Author
class BookInline(admin.TabularInline):
    model = Book
    fields = ["title", "slug", "isbn", "price", "published", "status"]
    prepopulated_fields = {"slug": ["title"]}
    extra = 0
    show_change_link = True
    ordering = ["-published"]


@admin.register(Author)
class AuthorAdmin(admin.ModelAdmin):
    list_display = ["name", "country", "book_count"]
    list_filter = ["country"]
    ordering = ["name"]  # annotate() with an aggregate ignores Meta.ordering
    search_fields = ["name"]  # required by BookAdmin.autocomplete_fields
    prepopulated_fields = {"slug": ["name"]}
    inlines = [BookInline]

    def get_queryset(self, request):
        return super().get_queryset(request).annotate(_book_count=Count("books"))

    @admin.display(description="Books", ordering="_book_count")
    def book_count(self, obj):
        return obj._book_count

    def save_formset(self, request, form, formset, change):
        # Inlines do not go through save_model(): stamp new books here.
        instances = formset.save(commit=False)
        for obj in formset.deleted_objects:
            obj.delete()
        for instance in instances:
            if isinstance(instance, Book) and instance.added_by_id is None:
                instance.added_by = request.user
            instance.save()
        formset.save_m2m()
An inline with a permission rule (catalog/admin.py)
class ReviewInline(admin.TabularInline):
    model = Review
    fields = ["reviewer", "rating", "body"]
    raw_id_fields = ["reviewer"]
    extra = 0

    def has_add_permission(self, request, obj):
        return False  # editors moderate reviews, they do not write them

Points to remember when writing inlines.

  • Inlines do not go through save_model(). The parent form is saved by save_model(), the inline rows by save_formset(); stamp inline objects there, as AuthorAdmin.save_formset() does. When formset.save(commit=False) is used, deleted objects are in formset.deleted_objects and must be deleted by hand.

  • The "extra" forms are ignored when the inline has no add permission. The ReviewInline test posts an extra review form and asserts that nothing is created.

  • Several foreign keys to one parent need fk_name. Without it the system check fails with admin.E202 ("has no ForeignKey to…​" or "has more than one ForeignKey to…​").

  • Many-to-many fields can be edited as inlines on the through table. The example below edits a genre’s books with an inline on Book.genres.through (and, on the other side of the relation, would exclude the genres field of BookAdmin so the field does not appear twice). Because the admin sees only a model with two foreign keys, the m2m_changed signal is not sent when rows are added or removed this way.

  • A model with an intermediary through model has no widget by default; an inline on the intermediary model (with extra = 1, for example) is the way to edit its extra columns.

  • Generic relations use GenericTabularInline or GenericStackedInline from django.contrib.contenttypes.admin.

  • get_inline_instances() must return instances of the classes listed in inlines. Otherwise adding related objects answers "Bad Request".

A many-to-many through table as an inline (catalog/admin.py)
# ---------------------------------------------------------------- Genre
class BookGenreInline(admin.TabularInline):
    """Many-to-many through table edited as an inline on the Genre page."""

    model = Book.genres.through
    extra = 0
    autocomplete_fields = ["book"]
    verbose_name = "book"
    verbose_name_plural = "books"


@admin.register(Genre)
class GenreAdmin(admin.ModelAdmin):
    search_fields = ["name"]  # required by BookAdmin.autocomplete_fields
    inlines = [BookGenreInline]

The dynamic get_inlines() hook shows an inline only on the change view.

Showing an inline only when editing (catalog/admin_examples.py)
class ReviewInlineOnChangeAdmin(admin.ModelAdmin):
    """Show the inline only when editing, never on the add form."""

    class ReviewInline(admin.StackedInline):
        model = Review
        fk_name = "book"
        extra = 1
        max_num = 5
        min_num = 0
        can_delete = False
        show_change_link = True
        classes = ["collapse"]

    inlines = [ReviewInline]

    def get_inlines(self, request, obj):
        return self.inlines if obj is not None else []

Actions

An action is a function called with the admin, the request and the queryset of the rows selected on the change list. Django ships "Delete selected objects"; the actions option adds yours. Write the function (or a ModelAdmin method) with the @admin.action decorator (Django 3.2), which sets the attributes the admin reads.

Argument Effect

description

The label in the drop-down. It supports %(verbose_name)s and %(verbose_name_plural)s placeholders. Defaults to the function name with underscores replaced by spaces.

permissions

A list of permission names; the action is offered only if the user passes at least one check. "add", "change", "delete" and "view" map to the has_<name>_permission() methods; any other name needs a has_<name>_permission(self, request) method you write.

description_plural (6.1)

The label used in the change list; defaults to description. Useful when the same action also appears on the change form.

location (6.1)

Where the action is available: ActionLocation.CHANGE_LIST (the default), ActionLocation.CHANGE_FORM, or a list of both. On the change form, unsaved edits are lost when an action runs.

Key rules from the documentation:

  • Actions are listed by name in actions: a string names a ModelAdmin method, a function reference names a function. actions = None disables every action for that admin, including the site-wide ones.

  • An action returns None (the user returns to the list), or an HttpResponse: a file download, or a redirect to an intermediate view that receives the selected ids. The built-in delete action does the latter for confirmation.

  • Report what happened with self.message_user(), and handle foreseeable errors there instead of letting them become a 500.

  • queryset.update() is the fast way to change many rows, but it does not call save() and sends no pre_save or post_save signals. Iterate and call save() when those matter.

  • The delete selected action calls QuerySet.delete(), which does not call each model’s delete() method. Override delete_queryset() or write your own action when you need that.

  • The admin does not check object-level permissions for the selected rows. If it matters, check self.has_change_permission(request, obj=obj) inside the action.

The BookAdmin actions: publish (available on the list and on the change form in 6.1) and withdraw, both gated by the custom publish_book permission, plus a CSV export for anyone who can view.

Actions of BookAdmin (BookAdmin, continued)
    # --- actions
    @admin.action(
        description="Publish",
        description_plural="Publish selected %(verbose_name_plural)s",
        permissions=["publish"],
        location=[ActionLocation.CHANGE_LIST, ActionLocation.CHANGE_FORM],
    )
    def publish(self, request, queryset):
        updated = queryset.update(status=Book.Status.PUBLISHED)
        self.message_user(
            request,
            ngettext("%d book was published.", "%d books were published.", updated)
            % updated,
            messages.SUCCESS,
        )

    @admin.action(
        description="Withdraw selected %(verbose_name_plural)s",
        permissions=["publish"],
    )
    def withdraw(self, request, queryset):
        updated = queryset.update(status=Book.Status.WITHDRAWN)
        self.message_user(request, f"{updated} withdrawn.", messages.WARNING)

    @admin.action(description="Export selected books as CSV", permissions=["view"])
    def export_csv(self, request, queryset):
        response = HttpResponse(content_type="text/csv")
        response["Content-Disposition"] = 'attachment; filename="books.csv"'
        writer = csv.writer(response)
        writer.writerow(["isbn", "title", "author", "price", "status"])
        for book in queryset.select_related("author"):
            writer.writerow(
                [book.isbn, _csv_safe(book.title), book.author.name, book.price, book.status]
            )
        return response

The CSV export protects against CSV (formula) injection: a title such as =SUM(A1) would run as a formula in a spreadsheet, so the helper prefixes risky cells with an apostrophe.

The CSV helper and a proxy admin that scopes its queryset (catalog/admin.py)
def _csv_safe(value):
    """Stop spreadsheets from running a cell that starts like a formula."""
    return f"'{value}" if value[:1] in ("=", "+", "-", "@") else value


# ----------------------------------------------------- scoping a queryset
@admin.register(MyBook)
class MyBookAdmin(BookAdmin):
    def get_queryset(self, request):
        return super().get_queryset(request).filter(added_by=request.user)

Source: Admin actions

An action available site-wide is registered on the site with AdminSite.add_action(function, name=None), and removed with AdminSite.disable_action(name); admin.site.disable_action("delete_selected") removes the built-in deletion everywhere, and listing "delete_selected" in one admin’s actions brings it back there. ModelAdmin.get_actions(request, action_location=…​) enables or disables actions per request; in 6.1 it takes the new action_location argument and returns Action objects instead of tuples (the dynamic-hooks example above removes delete_selected for non-superusers). The two site-wide actions below were registered on every example site.

Site-wide actions (catalog/admin_examples.py)
# -------------------------------------------------------------- site-wide
@admin.action(description="Export selected objects as JSON")
def export_as_json(modeladmin, request, queryset):
    response = HttpResponse(content_type="application/json")
    serializers.serialize("json", queryset, stream=response)
    return response



@admin.action(description="Say hello", permissions=["view"])
def say_hello(modeladmin, request, queryset):
    modeladmin.message_user(request, f"{queryset.count()} selected", messages.INFO)
Registering the site-wide actions on a site (from the test module)
site = admin.AdminSite(name="example4")
site.register(Author)
site.add_action(admin_examples.export_as_json, "export_json")
site.add_action(admin_examples.say_hello)

The test below posts the change-form action the way the browser does (the form fields are CHANGE_FORM-action, _selected_action and the submit button index; the change-list form uses action and _selected_action).

Testing the actions and their permission (catalog/tests.py)
class ActionTests(AdminTestBase):
    def post_action(self, action, *books):
        return self.client.post(
            reverse("admin:catalog_book_changelist"),
            {"action": action, ACTION_CHECKBOX_NAME: [b.pk for b in books]},
            follow=True,
        )

    def test_publish_and_withdraw_need_the_custom_permission(self):
        editor = make_user("editor", "view_book", "change_book")
        self.client.force_login(editor)
        page = self.client.get(reverse("admin:catalog_book_changelist"))
        self.assertNotContains(page, 'value="publish"')
        self.assertContains(page, 'value="export_csv"')
        self.post_action("publish", self.draft)
        self.draft.refresh_from_db()
        self.assertEqual(self.draft.status, "draft")  # unknown action: ignored

    def test_publisher_can_publish_in_bulk(self):
        publisher = make_user("publisher", "view_book", "change_book", "publish_book")
        self.client.force_login(publisher)
        response = self.post_action("publish", self.draft)
        self.assertContains(response, "1 book was published.")
        self.draft.refresh_from_db()
        self.assertEqual(self.draft.status, "published")

    def test_change_form_action_for_publishers_only(self):
        url = reverse("admin:catalog_book_change", args=[self.draft.pk])
        self.client.force_login(make_user("editor", "view_book", "change_book"))
        self.assertNotContains(self.client.get(url), 'name="CHANGE_FORM-action"')
        self.client.force_login(
            make_user("publisher", "view_book", "change_book", "publish_book")
        )
        self.assertContains(self.client.get(url), 'name="CHANGE_FORM-action"')
        response = self.client.post(
            url,
            {"CHANGE_FORM-action": "publish", "_selected_action": self.draft.pk,
             "CHANGE_FORM-select_across": "0", "index": "0"},
            follow=True,
        )
        self.assertContains(response, "1 book was published.")
        self.draft.refresh_from_db()
        self.assertEqual(self.draft.status, "published")

Source: The test client

Permissions in the admin

Authorisation in the admin has four layers, from the coarsest to the finest. A staff member passes every layer that applies, and superusers pass all of them.

  1. Entry: AdminSite.has_permission(request) requires is_active and is_staff. A non-staff user is redirected to the login page; an anonymous user too. This check cannot be bypassed by a model permission.

  2. Model permissions. Each model gets add, change, delete and (since Django 2.1) view permissions when migrate runs (catalog.view_book and so on), plus any in Meta.permissions (the catalog.publish_book of the Bookshelf). They are assigned to users or to groups. A user with only the view permission can open the change list and a read-only change form; the change permission implies the ability to view. The index page lists only the models for which the user has at least one permission, and a staff user without any permission on a model gets 403.

  3. Rules in code: has_view_permission(request, obj=None), has_add_permission(request), has_change_permission(request, obj=None) and has_delete_permission(request, obj=None). They default to the model permissions and are the place for business rules (for example, published books cannot be deleted). When obj is None the question is "in general", for the list and the "add" button. has_module_permission(request) only hides the app on the index; it does not protect the views behind it.

  4. Which rows: get_queryset(request) restricts what a user sees and edits (a queryset filtered by owner, for example). It is the only layer that is about rows rather than about operations.

The BookAdmin shows three rules: the custom publish permission (used by the actions and to make status read-only for everyone else), the rule that a published book cannot be deleted (it must be withdrawn first), and the copy-not-mutate pattern for get_readonly_fields().

Permission rules of BookAdmin (BookAdmin, continued)
    # --- permissions
    def has_publish_permission(self, request):
        codename = get_permission_codename("publish", self.opts)  # "publish_book"
        return request.user.has_perm(f"{self.opts.app_label}.{codename}")

    def has_delete_permission(self, request, obj=None):
        if obj is not None and obj.status == Book.Status.PUBLISHED:
            return False  # withdraw first, then delete
        return super().has_delete_permission(request, obj)

    def get_readonly_fields(self, request, obj=None):
        readonly = [*super().get_readonly_fields(request, obj)]  # a copy
        if not self.has_publish_permission(request):
            readonly.append("status")
        return readonly
Stamping the owner when saving (BookAdmin, continued)
    # --- saving
    def save_model(self, request, obj, form, change):
        if not change and obj.added_by_id is None:
            obj.added_by = request.user
        super().save_model(request, obj, form, change)

Things the admin does not do for you.

  • Object-level permissions are not visibility filters. has_view_permission(request, obj) stops someone opening one object, but the object can still appear in the change list, in related-field choices, in autocomplete results and in the "recent actions" panel. Restrict those by customising the querysets (get_queryset(), formfield_for_foreignkey()).

  • list_editable, actions and inlines have their own paths. A field made editable in the list can be changed by anyone with the change permission, so a "publish" rule must not rely on a field being editable in the form only (the example removes status from list_editable and makes it read-only without the permission).

  • Action permissions are checked per model, not per selected object. The built-in "delete selected" action does ask has_delete_permission(request, obj) for the selected objects (the test shows the "Cannot delete" page), but a custom action must call has_change_permission(request, obj=obj) itself.

  • The user must have a permission on the related model for autocomplete. A staff user with view_book but not view_author gets 403 from the author autocomplete.

  • Superusers pass every model-permission check (has_perm() is always true for them), but code you add in has_*_permission() still applies to them: the "published books cannot be deleted" rule blocks superusers too, as the "delete selected" test shows. Keep superuser accounts few, and use groups for everyone else.

  • The "view" permission was added in 2.1. A custom permission whose codename looks like view_<model> accidentally grants view access to the list and detail pages since then.

Row scoping is usually done with a proxy model: the proxy has its own admin page and its own four permissions (catalog.view_mybook, catalog.change_mybook, …​), and its admin filters the queryset by the current user. The MyBookAdmin shown with the CSV helper above does it in four lines. An object outside the queryset is not found, so the admin answers with the redirect it uses for a missing object (the user never learns whether it exists).

Groups are the natural way to assign permissions to roles: an "Editors" group with view_book and change_book, a "Publishers" group that adds publish_book. The tests create users with exactly the permissions they need, which is also the best way to document and verify your policy.

Testing the permission rules (catalog/tests.py)
def make_user(username, *codenames, staff=True):
    """A staff user holding exactly the given catalog permissions."""
    user = User.objects.create_user(username, password=None, is_staff=staff)
    user.user_permissions.add(*Permission.objects.filter(codename__in=codenames))
    return user

class PermissionTests(AdminTestBase):
    def test_non_staff_is_redirected_to_login(self):
        self.client.force_login(self.reader)
        response = self.client.get(reverse("admin:catalog_book_changelist"))
        self.assertEqual(response.status_code, 302)
        self.assertIn("/admin/login/", response["Location"])

    def test_staff_without_model_permission_gets_403(self):
        self.client.force_login(make_user("nobody"))
        self.assertEqual(self.client.get(reverse("admin:catalog_book_changelist")).status_code, 403)

    def test_view_permission_is_read_only(self):
        self.client.force_login(make_user("auditor", "view_book"))
        url = reverse("admin:catalog_book_change", args=[self.draft.pk])
        response = self.client.get(url)
        self.assertNotContains(response, 'name="_save"')
        self.assertEqual(self.client.post(url, {"title": "x"}).status_code, 403)

    def test_status_is_read_only_without_publish_permission(self):
        self.client.force_login(make_user("editor", "view_book", "change_book"))
        response = self.client.get(reverse("admin:catalog_book_change", args=[self.draft.pk]))
        self.assertNotContains(response, 'name="status"')
        self.client.force_login(make_user("publisher", "view_book", "change_book", "publish_book"))
        response = self.client.get(reverse("admin:catalog_book_change", args=[self.draft.pk]))
        self.assertContains(response, 'name="status"')

    def test_published_books_have_no_delete_button(self):
        self.client.force_login(make_user("deleter", "view_book", "change_book", "delete_book"))
        live = self.client.get(reverse("admin:catalog_book_change", args=[self.live.pk]))
        draft = self.client.get(reverse("admin:catalog_book_change", args=[self.draft.pk]))
        self.assertNotContains(live, "deletelink")
        self.assertContains(draft, "deletelink")
        self.assertEqual(
            self.client.post(reverse("admin:catalog_book_delete", args=[self.live.pk]), {"post": "yes"}).status_code,
            403,
        )

    def test_inline_add_permission_is_removed(self):
        self.client.force_login(self.superuser)
        other = User.objects.create_user("other", password=None)
        data = {
            "title": "Engines", "subtitle": "An essay", "slug": "engines",
            "author": self.author.pk, "price": "20.00", "isbn": "9780000000002",
            "published": "2023-01-01", "status": "published",
            "reviews-TOTAL_FORMS": "2", "reviews-INITIAL_FORMS": "1",
            "reviews-MIN_NUM_FORMS": "0", "reviews-MAX_NUM_FORMS": "1000",
            "reviews-0-id": Review.objects.get().pk, "reviews-0-book": self.live.pk,
            "reviews-0-reviewer": self.reader.pk, "reviews-0-rating": "5",
            "reviews-1-book": self.live.pk, "reviews-1-reviewer": other.pk,
            "reviews-1-rating": "1",
        }
        self.client.post(reverse("admin:catalog_book_change", args=[self.live.pk]), data)
        self.assertEqual(Review.objects.count(), 1)  # the extra form was ignored

    def test_group_permissions(self):
        group = Group.objects.create(name="Editors")
        group.permissions.set(Permission.objects.filter(codename__in=["view_book", "change_book"]))
        user = User.objects.create_user("grouped", password=None, is_staff=True)
        user.groups.add(group)
        self.client.force_login(user)
        self.assertEqual(self.client.get(reverse("admin:catalog_book_changelist")).status_code, 200)

    def test_autocomplete_needs_view_or_change_on_related_model(self):
        self.client.force_login(make_user("limited", "view_book"))
        url = reverse("admin:autocomplete")
        params = {"term": "ada", "app_label": "catalog", "model_name": "book", "field_name": "author"}
        self.assertEqual(self.client.get(url, params).status_code, 403)
        self.client.force_login(make_user("limited2", "view_book", "view_author"))
        response = self.client.get(url, params)
        self.assertEqual(response.status_code, 200)
        self.assertEqual(response.json()["results"][0]["text"], "Ada Lovelace")

    def test_my_books_scopes_rows(self):
        Book.objects.filter(pk=self.draft.pk).update(added_by=self.superuser)
        user = make_user("mine", "view_mybook", "change_mybook")
        Book.objects.filter(pk=self.live.pk).update(added_by=user)
        self.client.force_login(user)
        response = self.client.get(reverse("admin:catalog_mybook_changelist"))
        self.assertEqual([b.title for b in response.context["cl"].result_list], ["Engines"])
        other = reverse("admin:catalog_mybook_change", args=[self.draft.pk])
        self.assertEqual(self.client.get(other).status_code, 302)  # "doesn't exist" redirect

Management of users and groups themselves (the UserAdmin, group forms, custom user models) is in Custom User Model & Backends; the permission system outside the admin (permission_required, mixins, object permissions) is in Permissions & Authorization, and the login and password side in Authentication and Passwords.

The audit trail: LogEntry

Every add, change and delete done through the admin writes a LogEntry (django.contrib.admin.models.LogEntry: action_time, user, content_type, object_id, object_repr, action_flag, change_message). The "History" link of an object lists them (paginated since 4.1) and the admin index shows the "Recent actions" of the user. A site can filter the log shown on its index by overriding AdminSite.get_log_entries() (5.0). The log is written only by admin views: changes made in a shell, a management command or a public view leave no entry, and a custom action that uses queryset.update() writes no entries unless it creates them itself.

Sync and async

The admin is synchronous. ModelAdmin and AdminSite methods, the formsets and the templates are all blocking code, and there is no async variant of any hook. That is fine under ASGI: Django runs a synchronous view in a worker thread and the admin works unchanged. Two practical consequences:

  • Write hooks as ordinary def methods; the admin calls them synchronously. If an action must call async code, wrap the call with asgiref.sync.async_to_sync, as the Django async documentation describes, and keep it short: the admin request holds a worker thread while it waits.

  • A server that only speaks ASGI does not serve static files. Under uvicorn the admin pages rendered (the change list answered 200) but the admin CSS and JavaScript were a 404, whereas runserver serves them. In production serve the static files with WhiteNoise, the web server or a CDN (see Static Files & Front-end Assets).

The admin under an ASGI server (checked with uvicorn)
$ uvicorn config.asgi:application --port 8737 &
$ curl -s -o /dev/null -w "login page %{http_code}\n" http://127.0.0.1:8737/admin/login/
login page 200
$ curl -s -b cookies.txt -o /dev/null -w "changelist %{http_code}\n" http://127.0.0.1:8737/admin/catalog/book/
changelist 200
$ curl -s -o /dev/null -w "static css %{http_code}\n" http://127.0.0.1:8737/static/admin/css/base.css
static css 404

System checks catch configuration mistakes

Most admin mistakes are caught at start-up by the system checks (admin.E…​), before any request is served. A deliberately broken ModelAdmin shows the kind of message to expect.

Checks for a broken ModelAdmin (run in the shell, not part of the project)
bad = admin.AdminSite(name="bad")

class Broken(admin.ModelAdmin):
    list_display = ["title", "pages"]          # no such attribute
    list_editable = ["title"]                  # first column, no links
    autocomplete_fields = ["author"]           # AuthorAdmin has no search_fields
    readonly_fields = ["nope"]
    list_filter = ["nada"]
    fieldsets = [(None, {"fields": ["title", "title"]})]

bad.register(Book, Broken)
The messages of manage.py check for that admin
admin.E040 ModelAdmin must define "search_fields", because it's referenced by Broken.autocomplete_fields.
admin.E012 There are duplicate field(s) in 'fieldsets[0][1]'.
admin.E035 The value of 'readonly_fields[0]' refers to 'nope', which is not a callable, an attribute of 'Broken', or an attribute of 'catalog.Book'.
admin.E108 The value of 'list_display[1]' refers to 'pages', which is not a callable or attribute of 'Broken', or an attribute, method, or field on 'catalog.Book'.
admin.E116 The value of 'list_filter[0]' refers to 'nada', which does not refer to a Field.
admin.E124 The value of 'list_editable[0]' refers to the first field in 'list_display' ('title'), which cannot be used unless 'list_display_links' is set.

Removing MessageMiddleware and SessionMiddleware from MIDDLEWARE gives admin.E409 and admin.E410; registering the same model twice raises AlreadyRegistered when the module is imported.

Pitfalls

Symptom Cause Fix

The change list is slow and runs one query per row

A ForeignKey column or a callable that touches a relation, without select_related().

Name the fields in list_select_related, or annotate in get_queryset(); never use True (deprecated in 6.1).

The change list is slow with many filters

Facet counts run extra queries (24 more with six filters in the scratch project), and "full result count" is a second COUNT(*).

show_facets = admin.ShowFacets.NEVER (or the default ALLOW) and show_full_result_count = False.

Search is slow

icontains on unindexed columns, or a leading wildcard that no index can use.

Search fewer fields, use ^/istartswith on indexed columns, or override get_search_results() with full-text search (PostgreSQL SearchVector) or an external engine.

UnorderedObjectListWarning for a model with Meta.ordering

annotate() with an aggregate drops Meta.ordering from the query.

Set ordering on the ModelAdmin, as AuthorAdmin does.

A custom column is not sortable

Callables are not database fields.

@admin.display(ordering="field") (or an annotation, as average_rating does).

readonly_fields entry does not show

fields or fieldsets is set and does not list it.

Add the name to fields or fieldsets.

Added fields pile up request after request

A get_*() hook mutates the list returned by super().

Build a copy: [*super().get_readonly_fields(request, obj)].

save_model() seems to ignore a veto

save_model() and delete_model() are not validation hooks; they must still save and delete.

Validate in the form (clean()), or raise ValidationError there.

added_by is empty for objects created through an inline

Inlines are saved by save_formset(), not by save_model().

Override save_formset() as the author page does.

Bulk status change leaves updated_at or signals untouched

queryset.update() does not call save() and sends no signals.

Loop and save(update_fields=[…​]), or update the timestamp explicitly.

"Delete selected" does not run my delete() method

It uses QuerySet.delete().

Override delete_queryset(), or replace the action.

A user sees objects they should not in lists and dropdowns

Object-level has_view_permission() is not a visibility filter.

Scope get_queryset() and formfield_for_foreignkey().

403 on an autocomplete field

The user has no view or change permission on the related model.

Grant view_<related>, or drop the field from autocomplete_fields.

400 "Bad Request" when filtering by a query parameter

lookup_allowed() rejects lookups that are not local fields or list_filter paths.

List the field in list_filter, or override lookup_allowed() for that one lookup.

AlreadyRegistered at start-up

The model is registered twice, often because another app already registers it (User, Group).

admin.site.unregister(Model) first.

Markup in a column shows as text

The callable output is escaped.

Return format_html(); never mark_safe() on user input.

Admin pages unstyled in production

Static files are not served by an ASGI or WSGI server.

collectstatic plus WhiteNoise, a web server or a CDN.

An error when a non-numeric term is searched on 5.2

A price__exact-style lookup in search_fields receives text that is not a number; 6.1 skips such terms.

On 5.2 override get_search_results(), or search text fields only.

Operational advice from the documentation and from practice: the admin is a tool for trusted staff, not a front end. It is a model-centric interface, and the more of your business process you force through its hooks the more you fight it. When users need a task-oriented screen, write a view instead and keep the admin for curation. See Admin Customization for the reasoning and for securing the site.

What changed recently

Version Admin change

2.1

The view permission and ModelAdmin.has_view_permission() for read-only access.

3.2

The @admin.display and @admin.action decorators replace the old attributes (short_description, boolean, admin_order_field, allowed_permissions); the decorator also allows @property columns.

4.0

ModelAdmin.search_help_text, ModelAdmin.get_formset_kwargs(), a quick filter in the navigation sidebar.

4.1

AdminSite.get_app_list() can reorder apps and models; the history view is paginated; custom FieldListFilter subclasses can set the __in separator.

5.0

Facet counts (show_facets, ShowFacets); AdminSite.get_model_admin() and get_log_entries(); boolean on properties in list_display; related, choices and all-values filters accept multiple values.

5.1

list_display accepts related__field lookups.

5.2

The extrabody block in admin/base.html; URLField values render as links.

6.0

Font Awesome icons in the admin; AdminSite.password_change_form; message_user() defaults to INFO with its own style (pass messages.SUCCESS to keep the green icon).

6.1

Redesigned change forms (labels above fields, help text before the input); the wide fieldset class is removed; list_select_related = False selects only the foreign keys in list_display and True is deprecated; ModelAdmin.delete_confirmation_max_display; the location and description_plural arguments of @admin.action with ActionLocation and Action; get_actions() and get_action_choices() take action_location and get_actions() returns Action objects; non-text search_fields lookups skip terms that are not valid for the field; boolean icons for related boolean fields; the login view honours next for authenticated users.

Source: Django 6.1 release notes and the 6.0, 5.2, 5.1, 5.0, 4.1, 4.0, 3.2 and 2.1 release notes listed in References.

Where the books are outdated

The four books this section cross-checks were written for Django 1.11 to 2.1, and their admin chapters show their age in four places. The first is syntax: columns are configured by setting attributes on functions (short_description, admin_order_field, boolean) instead of @admin.display, and actions the same way instead of @admin.action. Both forms still work, but the decorators (3.2) are the documented ones and are the only way to configure a property column. The second is permissions: Django 1.11 and 2.0 predate the view permission (2.1), so advice that treats "can see in the admin" and "can change" as the same thing no longer holds; a read-only auditor group is a one-permission change today. The third is features that did not exist: list_display through related__field (5.1), facets (5.0), AdminSite.get_model_admin() (5.0), the 6.1 action locations and ActionLocation, and the fact that allow_tags (removed in 2.0) is gone, so markup in a column must go through format_html(). The fourth is hardening: the books' guidance to build custom admin pages with staff_member_required still works, but AdminSite.admin_view() (shown in Admin Customization) is the supported wrapper because it also adds never_cache and CSRF protection, and a chapter that marks user-supplied Markdown safe with mark_safe() is an XSS bug, not a pattern. What the books got right and still holds: the admin is for trusted staff and not an end-user UI, list_display, list_filter, search_fields, prepopulated_fields, raw_id_fields, date_hierarchy, inlines, admin actions and the get_urls() plus admin_view() pair for internal dashboards are all current.

Where to go next