skip to content

How do you register a model with Django's admin, and how do you choose the columns its change list shows?

level: juniorimportance: must knowfreq 62%

answer

  1. a module the admin discovers
  2. a function call or a class decorator
  3. a ModelAdmin subclass
  4. a tuple of column names

basics

~10 s

Register a model in the app's admin.py with admin.site.register(Ticket, TicketAdmin) or the @admin.register(Ticket) decorator on a ModelAdmin subclass. The change list shows the columns named in ModelAdmin.list_display, defaulting to the object's str.

solid answer

~40 s

The admin app autodiscovers each installed app's `admin.py`. In it you either call `admin.site.register(Ticket, TicketAdmin)` or decorate the `ModelAdmin` subclass with `@admin.register(Ticket)`; both register on the default site, and the decorator accepts `site=` for another one. Registering without a `ModelAdmin` gives the default options. The **change list** is the admin's per-model record page, and `list_display` chooses its columns: model field names, `__` paths to related fields (Django 5.1+), callables, `ModelAdmin` methods or model methods and properties. With no `list_display` you get a single column showing `__str__()`. A `ForeignKey` column shows the related object's `__str__()`, a boolean field shows yes/no icons, and `ManyToManyField` names are rejected because each row would need its own query.

code

python · 11 lines
python
from django.contrib import admin

from helpdesk.models import Customer, Ticket


@admin.register(Ticket)
class TicketAdmin(admin.ModelAdmin):
    list_display = ["subject", "status", "priority", "customer", "opened_at"]


admin.site.register(Customer)  # default ModelAdmin options

go deeper

for a junior

Know both registration forms, that they live in admin.py, and that list_display names the change list columns with str as the default.

for a middle

Explain autodiscovery, the five kinds of list_display entries, and the rendering rules for foreign keys, booleans and many-to-many fields.

for a senior

Show that you treat every list_display column as a query cost and keep registration consistent across several admin sites.

for a principal

Be ready to argue which models deserve an admin at all, since each registration is an internal UI you must secure and maintain.

## Where registration happens `django.contrib.admin` ships with an app config whose `ready()` calls `admin.autodiscover()`. That imports the `admin` module of every installed app, so the convention is to register models in `<app>/admin.py`. A model that is never registered simply does not appear in the admin. There are two equivalent ways to register: 1. **`admin.site.register(Ticket, TicketAdmin)`** — a function call on the default `AdminSite` instance. The `ModelAdmin` class is optional; `admin.site.register(Ticket)` uses the default options. It also accepts an iterable of models. 2. **`@admin.register(Ticket)`** — a class decorator on the `ModelAdmin` subclass. It takes one or more models and an optional `site=` keyword for a custom `AdminSite`. It raises `ValueError` if no model is passed or if the decorated class does not subclass `ModelAdmin`. Both paths end in `AdminSite.register()`, which raises `AlreadyRegistered` if the model is registered twice on the same site and `ImproperlyConfigured` for an abstract model. ## The change list and list_display The **change list** is the page at `/admin/<app>/<model>/`: a paginated table of records with search, filters and actions around it. `list_display` decides its columns. Without it, the page shows one column, the object's `__str__()`, which is why a readable `__str__` matters. `list_display` accepts five kinds of entries: | Entry | Example | Notes | |---|---|---| | Model field name | `"subject"` | sortable by clicking the header | | Related field with `__` | `"customer__email"` | added in Django 5.1 | | Callable taking the instance | `ticket_age` | defined at module level | | `ModelAdmin` method name | `"sla_state"` | `def sla_state(self, obj)` | | Model method or property name | `"is_overdue"` | no required arguments | ## Built-in rendering rules - A **`ForeignKey`** column shows the related object's `__str__()`. - A **`BooleanField`** column shows yes/no/unknown icons instead of `True`/`False`/`None`. - **`ManyToManyField`** names are not accepted: rendering them would run a separate query per row. Use a method that builds the text, knowing it costs queries. - Empty values (`None`, empty string, empty iterable) render as `-`, which `empty_value_display` changes. - Output from methods and callables is **HTML-escaped**; build markup with `format_html()`. - The first column links to the change form unless `list_display_links` says otherwise. ## A support-ticket example For a help-desk app, a first useful change list shows the subject, status, priority, the customer and when the ticket was opened. Everything else (filters, search, computed columns) builds on this registration. ## Other options that shape the list A handful of neighbouring `ModelAdmin` attributes control how that table behaves: | Attribute | Default | Effect | |---|---|---| | `list_display_links` | `()` | which columns link to the change form; empty means the first column, `None` means no links | | `list_per_page` | `100` | rows per page | | `list_max_show_all` | `200` | the "Show all" link appears only when the total is at or below this | | `ordering` | `None` | default sort; falls back to the model's `Meta.ordering` | | `sortable_by` | `None` | which columns get clickable headers; `None` means all sortable ones | | `empty_value_display` | the site's `-` | placeholder for empty values | When the resulting order is not guaranteed to be total, the change list appends `-pk` so pagination never shows a row twice or skips one. For a ticket queue, `ordering = ["-opened_at"]` and a `list_per_page` of 50 are typical first adjustments. ## Mistakes to avoid - Registering in `models.py`: it works only if that module is imported early, and it mixes admin concerns into the model layer. Put it in `admin.py`. - Registering the same model twice, often once with the decorator and once with a stray `admin.site.register()` call, raises `AlreadyRegistered` at startup. - Listing a reverse relation or many-to-many field directly in `list_display` and expecting it to render. - Forgetting that every extra column may add work per row; related columns and methods can multiply queries, which is a separate tuning question.

  • What does a Django admin change list show if list_display is not set?
    A single column containing each object's `__str__()`, linked to its change form. That is the default value of `list_display`, `("__str__",)`. It is why models that end up in the admin should define a meaningful `__str__`; otherwise staff see rows labelled like `Ticket object (42)`.
  • Why does Django's admin reject a ManyToManyField name in list_display?
    Showing a many-to-many value would need a separate query for every row on the page. The system checks refuse the field name. If the column is really needed, write a method that joins the related names and pair it with `prefetch_related` in `get_queryset()`, so the page still runs a fixed number of queries.

saying these in an interview costs you the question

  • Models appear in the admin automatically once they are migrated
  • admin.site.register() requires a ModelAdmin class as its second argument
  • list_display can show a ManyToManyField directly as a comma-separated column
  • A ForeignKey column in list_display shows the related row's numeric id
  • Registering a model twice on the same site just overrides the first registration