⚙️ Django Admin Interface Customization
Django hands you a complete, production-ready data-management UI for free — the admin. But the default view is only the starting point. With a handful of declarative options on a ModelAdmin class, you can turn that generic grid into a genuine back-office your team actually enjoys using.
🎯 Learning Objectives
By the end of this lesson, you will be able to:
- Register models with the admin using the
@admin.registerdecorator and aModelAdminclass - Shape the list view with
list_display,list_filter,search_fields, and inline editing - Add computed columns and organize the detail view with
fieldsets - Edit related rows with inlines and run batch operations with custom actions
- Add a custom list filter and rebrand the admin site's headers
Estimated Time: 40–50 minutes • Difficulty: Intermediate
Hands-on: Build a polished admin for a blog, including a computed column and a batch action.
In This Lesson
What the Admin Gives You Free
Register a model, log in, and Django already offers you searchable, paginated, permission-aware CRUD screens for your data — no HTML, no views, no forms written by hand. For internal tools and content management, this can save weeks of work.
💡 Analogy — the prefab house. The default admin is a prefabricated home: instantly habitable, with walls, plumbing, and power. It's functional but generic. Customizing it is the renovation — adding rooms (fieldsets), swapping fixtures (widgets), and wiring in smart features (actions and filters) until the space fits exactly how your team works.
⚠️ The admin is for staff, not the public
It's a powerful internal tool guarded by the is_staff flag and Django's permission system. Never expose it as your customer-facing UI — build normal views for that. Treat the admin as your operations dashboard.
Admin Architecture
A few cooperating pieces make the admin work. The one you'll touch daily is ModelAdmin — the class that declares how a single model looks and behaves.
- AdminSite — the container that owns the URLs and the index page.
- ModelAdmin — per-model configuration; where almost all your work happens.
- Inlines — edit related objects on the parent's page.
- Templates — the HTML you can override for deep visual changes.
Registering Models
The bare minimum is one line per model. The moment you want to customize, attach a ModelAdmin — and the decorator form is the modern idiom.
# admin.py — bare registration
from django.contrib import admin
from .models import Book
admin.site.register(Book)
# admin.py — the decorator idiom (preferred)
from django.contrib import admin
from .models import Book
@admin.register(Book)
class BookAdmin(admin.ModelAdmin):
pass # customization options go here
Both approaches are equivalent; @admin.register(Book) just keeps the class and its registration together.
Shaping the List View
The list view is the grid of all rows. A few options turn it from a wall of __str__ values into a real data console.
@admin.register(Book)
class BookAdmin(admin.ModelAdmin):
list_display = ("title", "author", "publisher", "price", "is_bestseller")
list_filter = ("publisher", "is_bestseller", "publication_date")
search_fields = ("title", "author__name", "isbn")
date_hierarchy = "publication_date"
ordering = ("-publication_date", "title")
list_per_page = 25
list_editable = ("price", "is_bestseller")
list_display_links = ("title",)
| Option | Effect |
|---|---|
list_display | Which fields appear as columns |
list_filter | Filter widgets in the right sidebar |
search_fields | A search box; use __ to span relations |
date_hierarchy | Drill-down date navigation across the top |
ordering | Default sort (prefix - for descending) |
list_editable | Edit fields inline, right in the grid |
list_display_links | Which column links to the detail page |
⚠️ list_editable and list_display_links can't overlap
A field can be an editable input or a link to the detail page, not both. If a name appears in list_editable, keep it out of list_display_links — Django raises a check error otherwise.
Computed Columns
Not every column has to be a stored field. Add a method to your ModelAdmin (or a property on the model) and list its name in list_display. The modern way to label and configure it is the @admin.display decorator.
from datetime import date
from django.contrib import admin
@admin.register(Book)
class BookAdmin(admin.ModelAdmin):
list_display = ("title", "author", "price", "age", "price_tier")
@admin.display(description="Age", ordering="publication_date")
def age(self, obj):
if obj.publication_date:
years = (date.today() - obj.publication_date).days // 365
return f"{years} years"
return "—"
@admin.display(description="Tier")
def price_tier(self, obj):
if obj.price < 10:
return "Budget"
elif obj.price < 30:
return "Regular"
return "Premium"
💡 Rendering HTML safely
To output markup from a computed column (a colored badge, a link), return django.utils.html.format_html(...). It escapes your interpolated values, so it's safe against injection — never build HTML with plain string formatting. Add ordering= to make a computed column sortable by the underlying database field.
The Detail View & Fieldsets
The detail view is the add/edit form. fieldsets groups fields into labeled sections — collapsible if you like — which tames long forms.
@admin.register(Book)
class BookAdmin(admin.ModelAdmin):
fieldsets = (
("Basic information", {
"fields": ("title", "author", "publisher"),
}),
("Publishing details", {
"fields": ("publication_date", "isbn", "page_count"),
"classes": ("collapse",),
"description": "Optional publication metadata.",
}),
("Sales", {
"fields": ("price", "stock", "is_bestseller"),
}),
)
autocomplete_fields = ("author",) # search-as-you-type FK picker
readonly_fields = ("isbn",)
save_on_top = True
fieldsets— group fields;"classes": ("collapse",)hides a section by default.autocomplete_fields— replaces a huge foreign-key dropdown with a search box (the target admin needssearch_fields).readonly_fields— display a value without letting it be edited.save_on_top— adds save buttons above the form as well as below.
Inlines
Inlines let you edit related rows on the parent's page — chapters while editing a book, comments while editing a post. Two flavors: TabularInline (compact rows) and StackedInline (full form per item).
from django.contrib import admin
from .models import Book, Chapter
class ChapterInline(admin.TabularInline):
model = Chapter
extra = 1 # one blank row to add a new chapter
fields = ("title", "page_start", "page_end")
min_num = 1
max_num = 50
@admin.register(Book)
class BookAdmin(admin.ModelAdmin):
inlines = [ChapterInline]
TabularInline for many simple related rows and StackedInline when each related object has lots of fields.Custom Actions
Actions run an operation over the rows a user has ticked in the list view. Select some rows, choose the action, hit Go. They're perfect for batch state changes and exports.
import csv
from django.contrib import admin
from django.http import HttpResponse
@admin.register(Book)
class BookAdmin(admin.ModelAdmin):
actions = ["mark_bestseller", "export_as_csv"]
@admin.action(description="Mark selected books as bestsellers")
def mark_bestseller(self, request, queryset):
updated = queryset.update(is_bestseller=True)
self.message_user(request, f"{updated} book(s) marked as bestsellers.")
@admin.action(description="Export selected books as CSV")
def export_as_csv(self, request, queryset):
response = HttpResponse(content_type="text/csv")
response["Content-Disposition"] = "attachment; filename=books.csv"
writer = csv.writer(response)
writer.writerow(["Title", "Author", "Price"])
for book in queryset:
writer.writerow([book.title, book.author, book.price])
return response
✅ Anatomy of an action
An action method takes (self, request, queryset), operates on queryset, and optionally returns an HttpResponse (for downloads). Prefer queryset.update(...) over looping and saving — it's one SQL statement instead of N. Use self.message_user() to report the result.
Filters & Branding
Custom list filter
When a built-in filter isn't enough, subclass SimpleListFilter to define your own sidebar options and the query behind each.
from django.contrib import admin
from django.utils import timezone
class PublishedThisYearFilter(admin.SimpleListFilter):
title = "published this year"
parameter_name = "this_year"
def lookups(self, request, model_admin):
return (("yes", "Yes"), ("no", "No"))
def queryset(self, request, queryset):
year = timezone.now().year
if self.value() == "yes":
return queryset.filter(published_at__year=year)
if self.value() == "no":
return queryset.exclude(published_at__year=year)
return queryset
@admin.register(Post)
class PostAdmin(admin.ModelAdmin):
list_filter = (PublishedThisYearFilter, "status")
Rebranding the site
The quickest branding change is three attributes on the default admin site — no custom AdminSite subclass required.
# admin.py (or your project's urls.py / apps.py)
from django.contrib import admin
admin.site.site_header = "Bookstore Administration"
admin.site.site_title = "Bookstore Admin"
admin.site.index_title = "Management dashboard"
Worked Example: Blog Admin
Here's a realistic admin that ties the pieces together — a slug auto-filled from the title, computed columns, comment inlines, and batch publish/draft actions.
# admin.py
from django.contrib import admin
from django.utils import timezone
from .models import Category, Post, Comment
class CommentInline(admin.TabularInline):
model = Comment
extra = 0
fields = ("author_name", "content", "is_approved")
readonly_fields = ("author_name", "content")
@admin.register(Post)
class PostAdmin(admin.ModelAdmin):
list_display = ("title", "author", "category", "status",
"comment_count", "published_at")
list_filter = ("status", "category", "created_at")
search_fields = ("title", "content")
prepopulated_fields = {"slug": ("title",)} # slug follows title
date_hierarchy = "created_at"
inlines = [CommentInline]
actions = ["make_published", "make_draft"]
save_on_top = True
@admin.display(description="Comments")
def comment_count(self, obj):
return obj.comments.count()
@admin.action(description="Publish selected posts")
def make_published(self, request, queryset):
updated = queryset.filter(status="draft").update(
status="published", published_at=timezone.now())
self.message_user(request, f"{updated} post(s) published.")
@admin.action(description="Move selected posts back to draft")
def make_draft(self, request, queryset):
updated = queryset.update(status="draft")
self.message_user(request, f"{updated} post(s) set to draft.")
def save_model(self, request, obj, form, change):
# Stamp the author on first save.
if not change:
obj.author = request.user
super().save_model(request, obj, form, change)
@admin.register(Category)
class CategoryAdmin(admin.ModelAdmin):
list_display = ("name", "slug")
prepopulated_fields = {"slug": ("name",)}
What this admin does
Editors get a searchable, date-navigable post list with a live comment count, a slug that types itself, inline comment moderation, one-click publish/draft actions, and automatic author stamping — all from one declarative class. That's a working CMS with no custom templates.
Hands-on Exercise
🏋️ Build a product admin
Objective: Customize the admin for a Product model with a computed column and a batch action.
Instructions
- Assume a
Productmodel withname,price,stock, andis_active(BooleanField). - Register it with a
ProductAdmin. Show all four fields inlist_display, addlist_filteronis_active, and a search box onname. - Add a computed column
stock_statusreturning "Out", "Low" (< 10), or "OK". - Add an action
deactivatethat setsis_active=Falseon the selected rows and reports how many changed.
💡 Hint
Decorate the computed method with @admin.display(description="Stock") and the action with @admin.action(description="…"). In the action, use queryset.update(is_active=False) — it returns the number of rows affected in one query.
✅ Solution
# admin.py
from django.contrib import admin
from .models import Product
@admin.register(Product)
class ProductAdmin(admin.ModelAdmin):
list_display = ("name", "price", "stock", "is_active", "stock_status")
list_filter = ("is_active",)
search_fields = ("name",)
actions = ["deactivate"]
@admin.display(description="Stock")
def stock_status(self, obj):
if obj.stock == 0:
return "Out"
elif obj.stock < 10:
return "Low"
return "OK"
@admin.action(description="Deactivate selected products")
def deactivate(self, request, queryset):
updated = queryset.update(is_active=False)
self.message_user(request, f"{updated} product(s) deactivated.")
One class delivers a filterable, searchable grid with a computed status column and a one-click batch action.
Summary & Quiz
🎉 Key Takeaways
- Registering a model gives you full CRUD instantly;
@admin.register+ aModelAdminis the modern pattern. - Shape the list view with
list_display,list_filter,search_fields, and inline editing. - Add computed columns with
@admin.display; returnformat_htmlfor safe markup. - Inlines edit related rows on the parent page; actions run batch operations over selected rows.
- Custom
SimpleListFilters and a few site attributes turn the admin into a tailored back-office.
🎯 Quick Quiz
Question 1: Which ModelAdmin option adds filter widgets to the list view's sidebar?
Question 2: What signature does a custom admin action method have?
Question 3: You want to edit a book's chapters on the book's own edit page. Which tool do you use?
📚 Further Reading
🚀 What's Next?
You've built server-rendered pages, forms, and an admin. Next we open the door to APIs: an overview of Django REST Framework, which lets your Django backend serve JSON to mobile apps and single-page frontends.
🎉 Great work!
You can now hand your team a real back-office. Time to build APIs.