skip to content

In Django's admin, how do you add a computed column to list_display, and what do @admin.display's arguments control?

level: middleimportance: should knowfreq 45%

answer

  1. a method that takes the object
  2. header text, icons, blank values
  3. sorting happens in the database
  4. escaped unless you build it safely

basics

~20 s

Add a ModelAdmin method (or model method, property or callable) taking the object to list_display. @admin.display sets its header (description), yes/no icons (boolean), the field or expression to sort by (ordering) and the placeholder for empty values (empty_value).

solid answer

~40 s

A computed column is a callable, a `ModelAdmin` method taking `obj`, or a model method or property, named in `list_display`. Decorating it with `@admin.display` configures it: `description` sets the column header, `boolean=True` renders `True`/`False`/`None` as icons, `ordering` names a field, a `__` lookup or a query expression to sort by, and `empty_value` replaces the dash shown for empty results. `boolean` and `empty_value` are mutually exclusive. Because Django sorts in the database, a computed column is **not sortable** unless `ordering` maps it to something the database can order by, often an annotation added in `get_queryset()`. The return value is HTML-escaped, so markup must be built with `format_html()`, never by concatenating user data into `mark_safe()`.

code

python · 25 lines
python
from django.contrib import admin
from django.db.models import Count
from django.utils.html import format_html

from helpdesk.models import Ticket


@admin.register(Ticket)
class TicketAdmin(admin.ModelAdmin):
    list_display = ["subject", "priority_badge", "reply_count", "awaiting_customer"]

    def get_queryset(self, request):
        return super().get_queryset(request).annotate(n_replies=Count("replies"))

    @admin.display(description="Replies", ordering="n_replies")
    def reply_count(self, obj):
        return obj.n_replies

    @admin.display(description="Priority", ordering="priority")
    def priority_badge(self, obj):
        return format_html('<span class="prio-{}">{}</span>', obj.priority, obj.get_priority_display())

    @admin.display(boolean=True, description="Waiting on customer")
    def awaiting_customer(self, obj):
        return obj.status == Ticket.Status.WAITING

go deeper

for a junior

Know that a method named in list_display becomes a column, and that @admin.display sets its header and yes/no icons.

for a middle

Explain ordering with fields, lookups, expressions and annotations, the boolean/empty_value exclusion, and why returned HTML is escaped.

for a senior

Show you annotate aggregates in get_queryset() instead of counting per row, and that you never mark user data safe in admin columns.

for a principal

Judge how much logic belongs in admin columns versus model methods shared with the rest of the product.

## Four places a computed column can live `list_display` accepts anything that produces a value for a row: - a **`ModelAdmin` method**, named as a string: `def sla_state(self, obj)`; the most common choice, because it keeps presentation logic in the admin; - a **model method or property** with no required arguments: `"is_overdue"`; good when the value is also used outside the admin; - a **module-level callable** taking the instance: `list_display = [ticket_age]`; - a **related field path** such as `"customer__email"` (Django 5.1+), which is not computed but covers the "show a field from another model" case without writing a method. ## What @admin.display configures `@admin.display` is a thin decorator that sets attributes the change list reads. Its keyword arguments: | Argument | Attribute it sets | Effect | |---|---|---| | `description` | `short_description` | column header text | | `boolean` | `boolean` | yes/no/unknown icons instead of `True`/`False`/`None` | | `ordering` | `admin_order_field` | what the database orders by when the header is clicked | | `empty_value` | `empty_value_display` | text shown when the value is empty | The decorator raises `ValueError` if `boolean` and `empty_value` are both passed, because an icon column has no text placeholder. Setting the long attribute names directly on the function still works, but the decorator is the documented style. On a property, `@property` must sit **above** `@admin.display`. ## Sorting a computed column Clicking a header sorts **in the database**, so the admin must translate the column into an `ORDER BY`. A plain method gives it nothing to translate, and the header is not clickable. `ordering=` fixes that: 1. `ordering="opened_at"` sorts by a field; prefix `-` for descending. 2. `ordering="customer__company__name"` follows relations. 3. `ordering=Concat("customer__first_name", Value(" "), "customer__last_name")` sorts by a query expression. 4. `ordering="reply_count"` sorts by an **annotation** you added in `get_queryset()`. The fourth pattern is the important one for counts and aggregates. Annotate once in `get_queryset()`, read the annotation in the method, and point `ordering` at it: the column is both cheap and sortable. ## HTML and escaping Values returned by methods and callables are escaped before rendering, so returning `"<b>urgent</b>"` shows the tags as text. To emit markup, use `format_html()`, which escapes each argument and marks the template safe. Calling `mark_safe()` on a string built from ticket subjects or customer names is a stored-XSS hole in a page staff trust. ## A support-ticket column set For a help-desk change list, typical computed columns are an SLA state ("breached", "at risk", "ok"), a reply count and a coloured priority badge. The SLA state is a method with a `description`; the reply count reads an annotation and sorts by it; the badge uses `format_html()`. A boolean "awaiting customer" column uses `boolean=True` so staff scan icons instead of words. ## Model method or ModelAdmin method? | Put the column on… | When | |---|---| | the `ModelAdmin` | the value is presentation for staff only: badges, links to other admin pages, formatted durations | | the model | the value is domain logic used elsewhere too, such as `is_overdue` in emails and APIs | | a module-level callable | the same column is reused across several `ModelAdmin` classes | A `ModelAdmin` method also has access to `self`, which means `self.admin_site` and URL reversing for admin links (`reverse("admin:helpdesk_customer_change", args=[obj.customer_id])`), something a model method should not know about. Keeping admin markup out of the model keeps `format_html()` calls and admin URLs out of the domain layer. ## Pitfalls - A method that touches `obj.customer.company` runs a query per row unless the change list joins those relations; the column looks innocent and multiplies queries. - Doing an aggregate per row (`obj.replies.count()`) instead of annotating. - Expecting `description` to make a column sortable; only `ordering` does. - Returning a boolean without `boolean=True` and getting the text `True`/`False`.

  • Why is a Django admin column showing obj.replies.count() both slow and unsortable?
    It runs one `COUNT` query per row, and the admin cannot sort by a Python method because sorting happens in the database. Annotate the count in `get_queryset()` with `Count("replies")`, return the annotation from the method, and set `@admin.display(ordering="n_replies")`. The page then runs one query for the counts and the header becomes clickable.
  • What goes wrong if a Django admin display method returns mark_safe(f"<b>{obj.subject}</b>")?
    The subject is user-supplied, so any HTML or script in it is rendered unescaped in the staff user's browser: a stored XSS in a privileged page. `format_html("<b>{}</b>", obj.subject)` escapes the argument while keeping the surrounding markup.

A computed column is like a label a clerk writes on each file by reading it: fine for a glance, but to sort the cabinet by that label you must give the filing system (the database) a rule it can apply itself, which is what ordering does.

saying these in an interview costs you the question

  • Setting description on a display method makes its column sortable
  • The admin sorts computed columns in Python after loading the page
  • boolean=True and empty_value can be combined on one display method
  • Wrapping HTML in mark_safe() is the recommended way to style a column
  • @admin.display only works on ModelAdmin methods, not on model properties