🛠️ Django Admin Interface Customization
Django hands you a complete administrative back office for free — one that reads and writes your database the moment you register a model. In this lesson you'll turn that generic panel into a purpose-built tool your editors, moderators, and staff will actually enjoy using.
🎯 Learning Objectives
By the end of this lesson, you will be able to:
- Register models and understand what the auto-generated admin gives you for free
- Write custom
ModelAdminclasses to control the list and change views - Add search, filters, custom columns, inlines, and bulk actions that fit your data
- Optimize admin queries with
select_related/prefetch_relatedand secure custom views with permissions - Assemble a complete, production-ready blog admin from these pieces
Estimated Time: 45–60 minutes • Difficulty: Intermediate
Hands-on: Build a customized admin for a small book-catalog app, complete with inlines and a bulk action.
In This Lesson
What the Django Admin Gives You
The Django admin is a fully functional web interface for managing your data — generated automatically from your models. Register a model and you immediately get create, read, update, and delete (CRUD) screens, authentication, permission checks, search, pagination, and relationship handling, without writing a single view or template.
💡 The building control-panel analogy: Think of the admin as the control room of a large building. Authorized staff can adjust the heating, lighting, and security without knowing the wiring behind the walls. The admin is that control room for your data — and Django wires it up for you the moment you register a model.
The admin is meant for trusted staff, not the public. It is the fastest way to get a working back office while you build the real user-facing site, and for many internal tools it is the whole product.
ModelAdmin config) and generates the list, form, and delete views that read and write your database.📖 Key Terms
Admin site: the singleton admin.site object that hosts all registered models at /admin/.
ModelAdmin: a configuration class that tells the admin how to display and edit one model.
Changelist: the paginated list view of all instances of a model.
Change form: the add/edit form for a single instance.
Registering Your First Model
Three things need to be in place before the admin will show your data: the admin app enabled, its URLs wired up, and your models registered.
1. Enable the admin app
A project created with django-admin startproject already includes these in settings.py:
INSTALLED_APPS = [
"django.contrib.admin",
"django.contrib.auth",
"django.contrib.contenttypes",
"django.contrib.sessions",
"django.contrib.messages",
"django.contrib.staticfiles",
"blog", # your own app
]
2. Wire up the admin URLs
# project/urls.py
from django.contrib import admin
from django.urls import path
urlpatterns = [
path("admin/", admin.site.urls),
]
3. Register a model
The most direct way — and the modern idiom — is the @admin.register decorator in your app's admin.py:
# blog/admin.py
from django.contrib import admin
from .models import Article, Category, Tag
admin.site.register(Category)
admin.site.register(Tag)
admin.site.register(Article)
💡 Create a superuser first
You need a staff account to log in. Run python manage.py createsuperuser, then visit http://127.0.0.1:8000/admin/.
That bare registration already gives you a working CRUD interface. But for anything beyond a toy model the default list — one column showing __str__(), no search, no filters — quickly becomes unusable. That is exactly what customization fixes.
Custom ModelAdmin Classes
To customize a model's admin, define a ModelAdmin subclass and attach it with the decorator. Everything you configure lives as class attributes:
# blog/admin.py
from django.contrib import admin
from .models import Article, Category, Tag
@admin.register(Article)
class ArticleAdmin(admin.ModelAdmin):
# --- List (changelist) view ---
list_display = ("title", "author", "status", "category", "created_at")
list_filter = ("status", "category", "created_at")
search_fields = ("title", "content", "author__username")
date_hierarchy = "created_at"
ordering = ("-created_at",)
list_per_page = 25
# --- Change form ---
prepopulated_fields = {"slug": ("title",)}
autocomplete_fields = ("author", "category")
filter_horizontal = ("tags",)
@admin.register(Category)
class CategoryAdmin(admin.ModelAdmin):
list_display = ("name", "slug")
search_fields = ("name",) # required for autocomplete_fields elsewhere
prepopulated_fields = {"slug": ("name",)}
@admin.register(Tag)
class TagAdmin(admin.ModelAdmin):
list_display = ("name", "slug")
search_fields = ("name",)
prepopulated_fields = {"slug": ("name",)}
⚠️ Autocomplete needs a searchable target
A field listed in autocomplete_fields points at a related model — that related model's own ModelAdmin must define search_fields, or Django raises a system-check error. That's why CategoryAdmin above declares search_fields = ("name",).
Shaping the List View
The changelist is where staff spend most of their time. A few attributes turn a wall of rows into a scannable, filterable table.
Custom columns
Any callable can become a column. Use the @admin.display decorator to name it, sort it, or render it as a boolean icon:
from django.utils import timezone
@admin.register(Article)
class ArticleAdmin(admin.ModelAdmin):
list_display = ("title", "author", "status", "is_recent")
list_display_links = ("title",)
list_editable = ("status",) # edit inline, no drilling in
empty_value_display = "—"
@admin.display(boolean=True, ordering="created_at", description="Recent?")
def is_recent(self, obj):
return obj.created_at >= timezone.now() - timezone.timedelta(days=7)
⚠️ A field can't be both a link and editable
Any field in list_editable must not also be in list_display_links. Keep at least one column (usually the title) as the click-through link.
Filters, search, and date navigation
list_filter = ("status", "category", "created_at", "author")
search_fields = ("title", "content", "author__username", "author__email")
date_hierarchy = "published_at"
Note the author__username lookup: double-underscore syntax lets search and filters span foreign keys, so you can find articles by their author's name.
Custom filters
When the built-in filters aren't enough, subclass SimpleListFilter. It has two jobs: return the choices, and translate the chosen value into a queryset filter:
from django.contrib.admin import SimpleListFilter
class PublishedYearFilter(SimpleListFilter):
title = "publication year"
parameter_name = "year"
def lookups(self, request, model_admin):
years = Article.objects.dates("published_at", "year")
return [(d.year, str(d.year)) for d in years]
def queryset(self, request, queryset):
value = self.value()
if value:
return queryset.filter(published_at__year=value)
return queryset
@admin.register(Article)
class ArticleAdmin(admin.ModelAdmin):
list_filter = ("status", PublishedYearFilter)
The Change Form & Inlines
The change form is where an individual object is edited. Group fields into logical sections with fieldsets, and edit related rows on the same page with inlines.
Organizing fields with fieldsets
@admin.register(Article)
class ArticleAdmin(admin.ModelAdmin):
fieldsets = (
("Content", {
"fields": ("title", "slug", "author", "content"),
}),
("Publishing", {
"fields": ("status", "category", "tags", "published_at"),
"classes": ("collapse",), # collapsed by default
}),
("SEO", {
"fields": ("meta_description", "meta_keywords"),
"description": "Search-engine metadata (optional).",
}),
)
readonly_fields = ("created_at", "updated_at")
prepopulated_fields = {"slug": ("title",)}
Inlines: editing related objects in place
Inlines let you edit child rows — an article's images or comments — right on the parent's page. Django offers two layouts:
| Inline class | Layout | Best for |
|---|---|---|
TabularInline | Compact rows in a table | Many rows, few fields (e.g. tags, line items) |
StackedInline | Each object as a stacked form | Few rows, many fields (e.g. rich profiles) |
from .models import Comment, ArticleImage
class ImageInline(admin.TabularInline):
model = ArticleImage
extra = 1 # blank forms to show
class CommentInline(admin.TabularInline):
model = Comment
extra = 0
readonly_fields = ("created_at",)
def has_add_permission(self, request, obj=None):
return False # comments come from the public site, not admin
@admin.register(Article)
class ArticleAdmin(admin.ModelAdmin):
inlines = [ImageInline, CommentInline]
Foreign-key widgets that scale
# Good when the related table is huge:
raw_id_fields = ("author",) # a lookup popup instead of a giant dropdown
# Better UX for medium tables — a type-ahead search box:
autocomplete_fields = ("category", "tags")
# Many-to-many with a dual-list picker:
filter_horizontal = ("tags",)
Bulk Actions
Actions operate on many selected rows at once. Django ships with "Delete selected"; adding your own is just a method plus the @admin.action decorator.
with the queryset] C --> D[Bulk update / export] D --> E[message_user feedback]
import csv
from django.http import HttpResponse
@admin.register(Article)
class ArticleAdmin(admin.ModelAdmin):
actions = ["make_published", "export_as_csv"]
@admin.action(description="Mark selected articles as published")
def make_published(self, request, queryset):
updated = queryset.update(status="published")
self.message_user(request, f"{updated} article(s) published.")
@admin.action(description="Export selected articles as CSV")
def export_as_csv(self, request, queryset):
response = HttpResponse(content_type="text/csv")
response["Content-Disposition"] = "attachment; filename=articles.csv"
writer = csv.writer(response)
writer.writerow(["Title", "Author", "Status", "Created"])
for article in queryset:
writer.writerow([
article.title,
article.author.get_username(),
article.status,
article.created_at.strftime("%Y-%m-%d"),
])
return response
💡 queryset.update() is one SQL statement
update() hits the database once for the whole selection — far faster than looping and calling save() on each object. The trade-off: it skips save() and does not fire the post_save signal. If you rely on custom save() logic, loop instead.
Worked Example: A Blog Admin
Here's the whole toolkit assembled into one realistic admin.py. Notice the get_queryset override — it pre-joins related tables so the changelist doesn't fire a separate query per row (the classic "N+1" problem).
# blog/admin.py
from django.contrib import admin
from django.utils.html import format_html
from .models import Article, Category, Tag, Comment
@admin.register(Category)
class CategoryAdmin(admin.ModelAdmin):
list_display = ("name", "slug", "article_count")
search_fields = ("name",)
prepopulated_fields = {"slug": ("name",)}
@admin.display(description="Articles")
def article_count(self, obj):
return obj.articles.count()
class CommentInline(admin.TabularInline):
model = Comment
extra = 0
fields = ("author", "content", "approved", "created_at")
readonly_fields = ("created_at",)
@admin.register(Article)
class ArticleAdmin(admin.ModelAdmin):
list_display = ("title", "author", "category", "status", "tag_list", "created_at")
list_filter = ("status", "category", "created_at")
search_fields = ("title", "content", "author__username")
date_hierarchy = "created_at"
list_per_page = 20
prepopulated_fields = {"slug": ("title",)}
autocomplete_fields = ("category",)
filter_horizontal = ("tags",)
readonly_fields = ("created_at", "updated_at")
inlines = [CommentInline]
actions = ["make_published"]
fieldsets = (
("Content", {"fields": ("title", "slug", "author", "content", "excerpt")}),
("Publishing", {
"fields": ("status", "category", "tags", "published_at"),
"classes": ("collapse",),
}),
("Timestamps", {
"fields": ("created_at", "updated_at"),
"classes": ("collapse",),
}),
)
def get_queryset(self, request):
# Avoid N+1 queries in the changelist.
return (
super()
.get_queryset(request)
.select_related("author", "category")
.prefetch_related("tags")
)
@admin.display(description="Tags")
def tag_list(self, obj):
return ", ".join(tag.name for tag in obj.tags.all())
@admin.action(description="Mark selected articles as published")
def make_published(self, request, queryset):
updated = queryset.update(status="published")
self.message_user(request, f"{updated} article(s) published.")
# Branding for the whole admin site
admin.site.site_header = "Blog Administration"
admin.site.site_title = "Blog Admin"
admin.site.index_title = "Content Management"
✅ Rendering safe HTML in a column
To show a thumbnail or a coloured badge in a column, return format_html(...) rather than a plain string. format_html escapes its arguments, so user-supplied text can't inject markup — never build admin HTML with f-strings or +.
Hands-on Exercise
🏋️ Build a book-catalog admin
Objective: Customize the admin for a tiny library app with two models: Author (name, slug) and Book (title, slug, author FK, genre, in_stock boolean, published date).
Requirements
BookAdmin: alist_displaywith title, author, genre, and an in-stock icon; a filter ongenreandin_stock; search by title and author name.- Prepopulate
slugfromtitle, and useautocomplete_fieldsforauthor. - Add a bulk action "Mark selected as out of stock".
- Give
AuthorAdminasearch_fieldsso the autocomplete works.
💡 Hint
The in-stock icon comes from @admin.display(boolean=True) on a method that returns obj.in_stock — or simply put "in_stock" straight into list_display, since a boolean field already renders as an icon. For search across the FK, use "author__name".
✅ Sample solution
# catalog/admin.py
from django.contrib import admin
from .models import Author, Book
@admin.register(Author)
class AuthorAdmin(admin.ModelAdmin):
list_display = ("name", "slug")
search_fields = ("name",)
prepopulated_fields = {"slug": ("name",)}
@admin.register(Book)
class BookAdmin(admin.ModelAdmin):
list_display = ("title", "author", "genre", "in_stock", "published")
list_filter = ("genre", "in_stock")
search_fields = ("title", "author__name")
date_hierarchy = "published"
prepopulated_fields = {"slug": ("title",)}
autocomplete_fields = ("author",)
actions = ["mark_out_of_stock"]
@admin.action(description="Mark selected as out of stock")
def mark_out_of_stock(self, request, queryset):
updated = queryset.update(in_stock=False)
self.message_user(request, f"{updated} book(s) marked out of stock.")
A boolean field placed directly in list_display renders as a green/red icon automatically — no custom method needed.
Best Practices
✅ Do
- Override
get_querysetwithselect_related/prefetch_relatedwhenever a column touches a related object. - Group fields with
fieldsetsand collapse rarely-used sections. - Use
@admin.displayand@admin.actiondecorators — they're clearer than the old.short_descriptionattribute assignments. - Return
format_html()for any HTML you inject into columns. - Restrict sensitive actions and custom views with permission checks.
⚠️ Don't
- Don't expose the admin to end users — it is a staff tool, not a public UI. Build real views for customers.
- Don't build column HTML with f-strings or string concatenation — that invites injection.
- Don't put a field in both
list_editableandlist_display_links. - Don't forget
search_fieldson any model referenced by anautocomplete_fieldsentry.
Summary & Quiz
🎉 Key Takeaways
- Registering a model gives you a full CRUD interface for free;
ModelAdminlets you shape it. list_display,list_filter,search_fields, anddate_hierarchymake the changelist usable.fieldsetsorganize the change form; inlines edit related rows on the same page.- Actions perform bulk operations;
@admin.actionregisters them cleanly. - Override
get_querysetto avoid N+1 queries, and useformat_htmlfor safe column markup.
🎯 Quick Quiz
Question 1: Which attribute controls the columns shown in a model's changelist (list) view?
Question 2: Why override get_queryset with select_related in a ModelAdmin?
Question 3: A field appears in autocomplete_fields. What must the related model's admin define?
📚 Further Reading
- Django docs — The admin site (ModelAdmin reference)
- Django docs — Admin actions
- Django tutorial, part 7 — Customizing the admin
🚀 What's Next?
The admin is powered by Django's form layer under the hood. Next we go straight to the source: building and validating Django forms for your own public-facing pages.
🎉 Nicely done!
You can now turn Django's free admin into a real back office. On to forms.