Introduction: Your free back office is waiting
Most teams building a Django backend spend weeks building custom admin dashboards that Django gives you for free. The Django admin, used well: what developers should know is the difference between a scattered toolbox and a professional operations interface. When configured properly, it handles 80% of your internal operations work without touching the application codebase.
We've seen this pattern across e-commerce, CRM, and SaaS projects: teams either ignore the admin entirely, treating it as a sandbox, or they spend months perfecting a bespoke internal tool that reinvents what's already there. The middle path—using Django's admin with discipline—saves months of work and keeps your team focused on user-facing features.
List display, search fields, and filters: your operational backbone
The admin list view is where your team spends 70% of their time in the back office. Getting it right means they can find, understand, and act on data without running queries or asking engineers for help.
list_display controls which columns appear on the list view. By default, you get the object's string representation. That's rarely enough.
from django.contrib import admin
from django.utils.html import format_html
from .models import Order
@admin.register(Order)
class OrderAdmin(admin.ModelAdmin):
list_display = ('order_id', 'customer_email', 'total_amount',
'status_badge', 'created_at')
def status_badge(self, obj):
colors = {
'pending': '#FFA500',
'confirmed': '#008000',
'shipped': '#0000FF',
'cancelled': '#FF0000'
}
color = colors.get(obj.status, '#808080')
return format_html(
'<span>{}</span>',
color, obj.get_status_display()
)
status_badge.short_description = 'Status'This single change transforms the list view from useless to actionable. Your ops team can see order status at a glance without clicking through.
search_fields lets your team find records by natural identifiers. Don't search on primary keys; search on what humans use.
@admin.register(Order)
class OrderAdmin(admin.ModelAdmin):
list_display = ('order_id', 'customer_email', 'total_amount', 'status_badge', 'created_at')
search_fields = ('order_id', 'customer__email', 'customer__name')
def status_badge(self, obj):
# ... status badge implementationNow a support agent can type "alice@example.com" and find all their orders instantly. This removes the "let me check in the database" friction.
list_filter enables drill-down by status, date range, and other dimensions. Use it to segment data without queries.
@admin.register(Order)
class OrderAdmin(admin.ModelAdmin):
list_display = ('order_id', 'customer_email', 'total_amount', 'status_badge', 'created_at')
search_fields = ('order_id', 'customer__email', 'customer__name')
list_filter = ('status', 'created_at', 'currency')
date_hierarchy = 'created_at'The date_hierarchy addition lets your team navigate by year, month, and day. For a high-volume SaaS, this is the difference between waiting 10 seconds for a list to load and loading it instantly with day-level scope.
Read-only fields: protecting sensitive data without hardening views
Your ops team needs to see payment details, API keys, and customer phone numbers to debug issues. But they shouldn't edit them directly—that's what your application logic is for. Read-only fields let you expose data safely.
@admin.register(Payment)
class PaymentAdmin(admin.ModelAdmin):
list_display = ('payment_id', 'order', 'amount', 'processor', 'status')
readonly_fields = ('payment_id', 'transaction_hash', 'processor_fee',
'created_at', 'last_webhook_at', 'webhook_status')
fieldsets = (
('Payment Details', {
'fields': ('payment_id', 'order', 'amount', 'currency', 'status')
}),
('Processor Information', {
'fields': ('processor', 'transaction_hash', 'processor_fee'),
'classes': ('collapse',)
}),
('Webhook Audit', {
'fields': ('created_at', 'last_webhook_at', 'webhook_status'),
'classes': ('collapse',)
})
)This structure lets a support agent see the full picture—what the customer paid, what the processor charged, and whether we received webhooks—without accidentally editing the transaction hash or payment ID. The collapsed sections keep the form clean while preserving access when needed.
Use get_readonly_fields to make fields read-only conditionally. For instance, locked payment records should be fully read-only, but draft records might allow edits.
def get_readonly_fields(self, request, obj=None):
if obj and obj.status == 'completed':
return self.readonly_fields + ('order', 'amount')
return self.readonly_fieldsCustom actions for bulk operations: automating repetitive work
Your team shouldn't be clicking "edit" on 50 orders to mark them as shipped. Custom actions let them select records and process them in bulk.
from django.utils.translation import ngettext
from django.contrib import messages
def mark_as_shipped(modeladmin, request, queryset):
updated = queryset.filter(status='confirmed').update(status='shipped')
modeladmin.message_user(
request,
ngettext(
'%d order was successfully marked as shipped.',
'%d orders were successfully marked as shipped.',
updated,
) % updated,
messages.SUCCESS,
)
mark_as_shipped.short_description = "Mark selected orders as shipped"
@admin.register(Order)
class OrderAdmin(admin.ModelAdmin):
list_display = ('order_id', 'customer_email', 'total_amount', 'status_badge', 'created_at')
search_fields = ('order_id', 'customer__email', 'customer__name')
list_filter = ('status', 'created_at')
actions = [mark_as_shipped]Now a team member selects 30 confirmed orders, hits "Mark as shipped," and confirms. The action runs once. No spreadsheets, no scripts, no manual updates.
For complex operations, actions can redirect to a form page where you collect parameters.
def refund_selected(modeladmin, request, queryset):
if request.POST.get('post') == 'yes':
reason = request.POST.get('reason', 'No reason provided')
updated = queryset.update(status='refunded', refund_reason=reason)
modeladmin.message_user(request, f'{updated} orders refunded.')
return
return render(request, 'admin/refund_confirmation.html', {
'orders': queryset,
'action': 'refund_selected'
})
refund_selected.short_description = "Refund selected orders with reason"
@admin.register(Order)
class OrderAdmin(admin.ModelAdmin):
actions = [mark_as_shipped, refund_selected]This pattern is powerful: the action collects a refund reason before processing, creating an audit trail and preventing accidental refunds with no justification.
Restrict with staff permissions: keeping the back office secure
Not everyone on your team should see everything. Django's permission system integrates directly into the admin. Use it.
from django.contrib.auth.decorators import user_passes_test
from django.utils.decorators import method_decorator
@admin.register(Payment)
class PaymentAdmin(admin.ModelAdmin):
list_display = ('payment_id', 'order', 'amount', 'processor', 'status')
readonly_fields = ('payment_id', 'transaction_hash', 'processor_fee',
'created_at', 'last_webhook_at', 'webhook_status')
def has_add_permission(self, request):
return False # Payments created by app logic only
def has_delete_permission(self, request, obj=None):
return False # No deletion
def has_change_permission(self, request, obj=None):
return request.user.groups.filter(name='Finance').exists()
def get_queryset(self, request):
qs = super().get_queryset(request)
if not request.user.is_superuser:
# Non-admins see only their organization's payments
return qs.filter(order__organization=request.user.organization)
return qsThis admin enforces several rules: payments can only be viewed and audited, never added or deleted directly; only Finance team members can access this admin; and non-superusers see only their organization's data.
For more granular control, use custom permissions tied to your business logic.
class Payment(models.Model):
# ... fields
class Meta:
permissions = [
("can_refund_payment", "Can refund payments"),
("can_view_processor_fee", "Can view processor fees"),
]
@admin.register(Payment)
class PaymentAdmin(admin.ModelAdmin):
def refund_action(self, request, queryset):
if not request.user.has_perm('myapp.can_refund_payment'):
raise PermissionDenied
# ... refund logic
refund_action.short_description = "Refund selected"
actions = [refund_action]Now refunds require explicit permission. Your support team might view payments, but only Finance can issue refunds. This separation keeps your audit logs clean and your compliance team happy.
Common mistakes
- Exposing too much in list view: If
list_displayhas 12 columns, the table is useless. Stick to 4–6 columns that answer "what is this record and what's its status?" - Searching on the wrong fields: Searching on internal ID when users think in order numbers or email addresses frustrates your team. Ask them how they actually look up records, then configure search fields to match.
- Forgetting
ordering: Addordering = ('-created_at',)to your admin so newest records appear first. Without it, pagination becomes painful at scale. - Making actions too powerful: A bulk action that can't be undone (like delete) should require a confirmation form, not just a button click. Test it with your ops team first.
- Leaving permissions wide open: "Everyone is staff" doesn't scale. Set up groups and permissions early, even if you only have five users now.
- Ignoring
list_select_relatedandprefetch_related: If your admin list makes N+1 queries, it will crawl at scale. Use these to batch load related objects.
@admin.register(Order)
class OrderAdmin(admin.ModelAdmin):
list_display = ('order_id', 'customer_email', 'total_amount', 'status_badge', 'created_at')
def get_queryset(self, request):
qs = super().get_queryset(request)
return qs.select_related('customer', 'payment').prefetch_related('items')Key takeaways
- Configure
list_display,search_fields, andlist_filterto make your ops team independent. They should find and understand records without asking engineers for queries. - Use
readonly_fieldsto expose sensitive data safely. Let your team audit payment details and API interactions without risking accidental edits. - Build custom actions for repetitive bulk work. A "Mark as shipped" action saves your team hours a month and creates an audit trail.
- Lock down permissions early. Use Django's permission system to separate concerns: viewing payments is fine, refunding them is not.
- Test your admin with real team members. What feels logical to you might be confusing to someone using it daily under pressure.
- Optimize queries with
select_relatedandprefetch_relatedbefore load becomes a problem. The admin can scale to hundreds of thousands of records if you get this right.






Comments
Loading comments…