Introduction
Every web app that stores data eventually needs a back office: a place to add records, fix typos, and unpublish the post you regret. Building that interface by hand is dull, repetitive work. Django's answer is to give you one for free. The Django admin reads your models and generates a complete, secure, searchable management site, and this blog's entire writing workflow lives inside it.
The Problem the Admin Solves
Most projects need the same internal tooling, over and over:
- A list of records you can search, filter, and sort
- Forms to create and edit each record, with validation
- Login, permissions, and an audit trail of who changed what
- A way for non-developers to manage content without touching code
Writing all of that for every model is a lot of work that has nothing to do with what makes your app special. Many teams skip it and end up editing the database by hand, which is risky.
The Django Admin Approach
You register a model, and Django builds the interface from the model's own definition: field types become form widgets, relations become pickers, and validation rules carry over automatically.
Core idea: Your models already describe your data. The admin turns that description into a working management UI, and you customize only the parts that matter.
Simple Example
# blog/admin.py
from django.contrib import admin
from .models import Post
admin.site.register(Post)
Create a user with python manage.py createsuperuser, run the server, and visit /admin/. You get a list of posts, an add form, an edit form, a delete confirmation, and a change history, from three lines of code.
How This Blog Uses the Admin
The Post model stores Markdown in body_md and renders it to sanitized HTML when you save. The admin is where that happens: you write Markdown, click save, and the model's save() method does the rest. Here is a more tailored admin for a blog like this one:
# blog/admin.py
from django.contrib import admin
from django.utils import timezone
from django.utils.safestring import mark_safe
from .models import Post, Tag
@admin.register(Tag)
class TagAdmin(admin.ModelAdmin):
list_display = ("name", "slug")
search_fields = ("name",)
prepopulated_fields = {"slug": ("name",)}
@admin.register(Post)
class PostAdmin(admin.ModelAdmin):
list_display = ("title", "is_published", "published_at", "updated_at")
list_editable = ("is_published",)
list_filter = ("is_published", "tags")
search_fields = ("title", "summary", "body_md")
prepopulated_fields = {"slug": ("title",)}
filter_horizontal = ("tags",)
date_hierarchy = "published_at"
readonly_fields = ("preview", "created_at", "updated_at")
actions = ["publish", "unpublish"]
@admin.display(description="Preview")
def preview(self, obj):
# Safe because body_html is sanitized with nh3 when the post is saved.
return mark_safe(obj.body_html)
@admin.action(description="Publish selected posts")
def publish(self, request, queryset):
updated = queryset.update(is_published=True, updated_at=timezone.now())
self.message_user(request, f"{updated} post(s) published.")
@admin.action(description="Unpublish selected posts")
def unpublish(self, request, queryset):
updated = queryset.update(is_published=False, updated_at=timezone.now())
self.message_user(request, f"{updated} post(s) unpublished.")
What's happening:
list_displayandlist_editableturn the post list into a dashboard where you can toggle publishing right from the tablelist_filterandsearch_fieldsadd a sidebar filter and a search box with no extra workprepopulated_fieldsfills in the slug as you type the title, and you can override it before savingfilter_horizontalgives tags a friendly two-column picker instead of a long multi-selectdate_hierarchyadds date drill-down navigation across the top of the list- The
previewmethod shows the rendered HTML on the edit page, sincebody_htmlis not editable - Actions let you publish or unpublish many posts in one click
A Detail Worth Knowing
queryset.update() writes straight to the database and skips the model's save() method. That's why the actions above set updated_at themselves. It also means that if you ever bulk-update body_md this way, body_html would not be re-rendered. For changes that must run save(), loop over the objects and save each one.
Also notice that the model keeps objects as an unfiltered manager. That's what allows the admin to show drafts and future-dated posts, while the public site uses Post.published.
Why This Matters
-
Instant productivity โ A usable back office appears the moment your models exist.
-
Safe by default โ Authentication, permissions, CSRF protection, and form validation come built in.
-
Customizable in small steps โ Start with
register()and add options only when you need them. -
Built-in accountability โ The history view records who changed each object, and when.
-
A content workflow with no extra code โ Authors write in forms; developers don't build editors.
Key ModelAdmin Options
| Option | Purpose | Example |
|---|---|---|
list_display |
Columns in the list view | ("title", "published_at") |
list_filter |
Sidebar filters | ("is_published", "tags") |
search_fields |
Fields the search box queries | ("title", "summary") |
prepopulated_fields |
Auto-fill a field from others | {"slug": ("title",)} |
readonly_fields |
Show values without editing them | ("created_at",) |
actions |
Bulk operations on selected rows | ["publish"] |
date_hierarchy |
Date drill-down navigation | "published_at" |
Real-World Use Cases
The admin is a good fit for:
- Content management โ Posts, pages, tags, and categories, as on this blog
- Internal tools โ Support teams viewing and correcting customer records
- Moderation โ Approving comments, hiding listings, banning accounts
- Early-stage products โ A working back office before you have time to build a custom one
- Data fixes โ Safe, validated edits instead of raw SQL
Getting Started
-
Check that it's enabled.
django.contrib.adminis inINSTALLED_APPSby default, andpath("admin/", admin.site.urls)is in your rooturls.py. -
Apply migrations and create a user:
bash python manage.py migrate python manage.py createsuperuser -
Register a model in the app's
admin.py. -
Open
/admin/and add a record. -
Customize one thing at a time: add
list_display, thensearch_fields, then an action.
Locking It Down in Production
The admin is powerful, so treat it like the front door of your back office:
- Use HTTPS only, and set secure cookie settings such as
SESSION_COOKIE_SECURE = TrueandCSRF_COOKIE_SECURE = True - Use strong, unique passwords for every staff account, and give people only the permissions they need
- Consider moving the URL from
/admin/to something less obvious, or restricting it by IP address in your Nginx config - Keep Django updated, since security fixes ship regularly
- Add two-factor authentication with a third-party package if several people have access
Know the Limits
- It's an internal tool, not a customer interface. Don't expose it to end users.
- Complex workflows get awkward. Multi-step processes and heavily customized screens are usually better as dedicated views.
- Large tables need care. Unindexed filters and huge
list_displayqueries can slow down the list page. - The design is functional, not branded. It can be themed, but it's meant to be practical, not pretty.
The Philosophy
The Django admin embodies a simple idea: boring, repetitive work should be generated, not written. By turning your models into a management interface automatically, it lets you spend your effort on the parts of the product that are actually yours.
Register your models, customize what annoys you, and let the admin be exactly what it's good at: a fast, safe way to look after your data.
This post is part of a series on the tools behind this blog: Django, HTMX, Tailwind CSS, PostgreSQL, SQLite, Docker, Debian on a VPS, tmux, SSH, Neovim, and now the Django admin. Posts are authored in the Django admin as Markdown and rendered to HTML on save.