Skip to main content

🛠️ 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 ModelAdmin classes 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_related and 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.

How the Django admin is generated Models are registered with the admin site, which reads their fields to generate list, change, add, and delete views that read and write the database. Models your data shapes Admin Site reads fields + ModelAdmin config → generates views List & search Add / change form Delete / actions
Figure 1 — The admin site inspects your models (plus any 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 classLayoutBest for
TabularInlineCompact rows in a tableMany rows, few fields (e.g. tags, line items)
StackedInlineEach object as a stacked formFew 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.

flowchart LR A[Select rows] --> B[Choose action] B --> C[Django calls method
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

  1. BookAdmin: a list_display with title, author, genre, and an in-stock icon; a filter on genre and in_stock; search by title and author name.
  2. Prepopulate slug from title, and use autocomplete_fields for author.
  3. Add a bulk action "Mark selected as out of stock".
  4. Give AuthorAdmin a search_fields so 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_queryset with select_related/prefetch_related whenever a column touches a related object.
  • Group fields with fieldsets and collapse rarely-used sections.
  • Use @admin.display and @admin.action decorators — they're clearer than the old .short_description attribute 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_editable and list_display_links.
  • Don't forget search_fields on any model referenced by an autocomplete_fields entry.

Summary & Quiz

🎉 Key Takeaways

  • Registering a model gives you a full CRUD interface for free; ModelAdmin lets you shape it.
  • list_display, list_filter, search_fields, and date_hierarchy make the changelist usable.
  • fieldsets organize the change form; inlines edit related rows on the same page.
  • Actions perform bulk operations; @admin.action registers them cleanly.
  • Override get_queryset to avoid N+1 queries, and use format_html for 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

🚀 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.