The first time my model appeared in Django admin, seeing “Article object (7)” felt like success with an asterisk. Registration had worked, but the interface knew almost nothing about how a human would find, review, or safely edit that record. A good ModelAdmin closes that gap.

Quick answer

polls/admin.pypython
from django.contrib import admin
 
from .models import Question
 
 
@admin.register(Question)
class QuestionAdmin(admin.ModelAdmin):
    list_display = ["question_text", "pub_date"]
    search_fields = ["question_text"]
    list_filter = ["pub_date"]

Registration and presentation stay together

  • @admin.register(Question) registers the model with the default admin site.

  • The decorated class holds display, form, query, and permission behavior for that model.

  • list_display replaces the single default object-label column with useful columns.

  • search_fields enables admin search and list_filter adds a filter sidebar.

  • Importing polls.admin happens through Django app discovery when the app and admin are installed.

A realistic model to administer

polls/models.pypython
from django.db import models
 
 
class Question(models.Model):
    question_text = models.CharField(max_length=200)
    pub_date = models.DateTimeField("date published")
    is_active = models.BooleanField(default=True)
 
    def __str__(self) -> str:
        return self.question_text
 
 
class Choice(models.Model):
    question = models.ForeignKey(
        Question, on_delete=models.CASCADE, related_name="choices"
    )
    choice_text = models.CharField(max_length=200)
    votes = models.PositiveIntegerField(default=0)
 
    def __str__(self) -> str:
        return self.choice_text

Readable labels matter beyond the admin

  • Django uses str(object) in default admin choices, logs, the shell, and other debugging contexts.

  • Return a concise human label rather than a large text body, secret, or expensive relationship query.

  • The type annotation documents that __str__ returns text.

  • related_name="choices" gives the reverse relationship a meaningful name for inlines and queries.

  • Changing Python presentation code needs no migration; changing model fields does.

Build a useful change list and edit form

polls/admin.pypython
from django.contrib import admin
 
from .models import Choice, Question
 
 
class ChoiceInline(admin.TabularInline):
    model = Choice
    extra = 0
    fields = ["choice_text", "votes"]
 
 
@admin.register(Question)
class QuestionAdmin(admin.ModelAdmin):
    list_display = ["question_text", "pub_date", "is_active"]
    list_display_links = ["question_text"]
    list_filter = ["is_active", "pub_date"]
    search_fields = ["question_text"]
    date_hierarchy = "pub_date"
    ordering = ["-pub_date"]
    list_per_page = 50
    fields = ["question_text", "pub_date", "is_active"]
    inlines = [ChoiceInline]

Each option solves a different staff task

  • list_display chooses columns; list_display_links chooses which column opens the record.

  • Date and boolean filters let staff narrow the list without inventing query parameters.

  • Text search is convenient but can be expensive on large tables because its database lookup depends on configured fields and terms.

  • date_hierarchy adds time-based navigation and ordering makes recency predictable.

  • Pagination limits each response; it does not remove the cost of an expensive count or search.

  • A TabularInline edits child choices with their parent and works best for small bounded relationships.

  • fields is an allowlist for the form; sensitive, generated, or operational fields should not appear casually.

Show a calculated column safely

polls/admin.pypython
@admin.register(Question)
class QuestionAdmin(admin.ModelAdmin):
    list_display = ["question_text", "pub_date", "choice_count"]
 
    @admin.display(ordering="choice_total", description="Choices")
    def choice_count(self, obj: Question) -> int:
        return obj.choice_total
 
    def get_queryset(self, request):
        return super().get_queryset(request).annotate(
            choice_total=models.Count("choices")
        )

Avoid one query per row

  • A method in list_display can calculate presentation, and @admin.display supplies its label and ordering expression.

  • Annotating once makes the count part of the list query instead of calling choices.count() for every row.

  • This snippet also needs from django.db import models in admin.py.

  • For foreign-key labels, use list_select_related or a tailored get_queryset() where measurement shows repeated queries.

  • Inspect database queries and plans with realistic data; admin convenience can hide costly list pages.

Run Django’s configuration checks

Django project rootbash
python manage.py check
python manage.py check --deploy --settings=project.settings.production
System check identified no issues (0 silenced).

Checks catch configuration, not workflow mistakes

  • The general check detects invalid admin options such as unknown fields and incompatible list settings.

  • The deployment check evaluates important security settings against the named production configuration.

  • A clean result does not prove staff have the correct permissions or that queries scale.

  • Run tests and manually exercise create, edit, delete, filters, search, pagination, and inline validation.

Test registration and access

polls/tests/test_admin.pypython
from django.contrib import admin
from django.contrib.auth import get_user_model
from django.test import TestCase
from django.urls import reverse
 
from polls.models import Question
 
 
class QuestionAdminTests(TestCase):
    def test_question_is_registered(self):
        self.assertIn(Question, admin.site._registry)
 
    def test_staff_can_open_question_list(self):
        user = get_user_model().objects.create_superuser(
            username="admin-test", password="test-only-password"
        )
        self.client.force_login(user)
        response = self.client.get(reverse("admin:polls_question_changelist"))
        self.assertEqual(response.status_code, 200)

Test behavior, not just imports

  • The registry assertion confirms the model’s admin module was discovered.

  • The admin URL name follows admin:app_label_model_name_changelist.

  • force_login isolates admin authorization and rendering from the login form.

  • Use a clearly test-only password; the test database is created separately and destroyed afterward.

  • Add limited-staff tests proving allowed and denied operations, plus query-count tests for expensive custom columns.

Permissions and data visibility

  • Model add, change, delete, and view permissions govern the standard admin actions.

  • A superuser bypasses normal permission checks; use limited staff accounts for routine work.

  • Override has_view_permission, has_change_permission, or related hooks only with tested, comprehensible rules.

  • Object-level permissions are not automatically provided by Django’s core model permission system.

  • Restricting buttons is not enough: scope get_queryset() so a user cannot retrieve another tenant’s rows.

  • Protect foreign-key and autocomplete querysets too, or forms may disclose out-of-scope objects.

When a registered model does not appear

  • The app is absent from `INSTALLED_APPS`: Django will not discover its normal admin module.

  • The model is not registered: add the decorator or admin.site.register; imported models do not register themselves.

  • The user lacks view/change permission: test with the intended staff role rather than assuming a rendering bug.

  • The model uses another admin site: confirm the URL points to the same AdminSite instance used for registration.

  • An import error stops autodiscovery: read startup logs and run manage.py check.

  • The model was already registered: remove the duplicate registration or deliberately unregister before replacing it.

  • The table is missing: create and apply migrations; registration does not create database schema.

Admin design checklist

  • Give models concise, safe __str__ labels.

  • Expose only fields staff need and make immutable values readonly.

  • Design list columns, links, filters, search, ordering, and pagination for the actual workflow.

  • Use inlines only when the relationship size and validation experience remain manageable.

  • Measure relationship columns and computed fields for N+1 queries.

  • Test least-privilege roles and tenant/object scoping.

  • Keep destructive actions explicit, auditable, and reversible where possible.

  • Treat the admin as production software: HTTPS, secure cookies, monitoring, upgrades, and backups still apply.

Official Django references