In Django's admin, how do you add a computed column to list_display, and what do @admin.display's arguments control?
answer
- a method that takes the object
- header text, icons, blank values
- sorting happens in the database
- escaped unless you build it safely
basics
~20 sAdd 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 sA 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 linesfrom 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.WAITINGgo deeper
Know that a method named in list_display becomes a column, and that @admin.display sets its header and yes/no icons.
Explain ordering with fields, lookups, expressions and annotations, the boolean/empty_value exclusion, and why returned HTML is escaped.
Show you annotate aggregates in get_queryset() instead of counting per row, and that you never mark user data safe in admin columns.
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