How to Fix Slow Django Admin Pages (Step-by-Step Guide)
Fix slow Django admin pages with list_select_related, faster pagination, smarter search, autocomplete fields, and lean inlines. Practical code for big tables.
How to Fix Slow Django Admin Pages
The Django admin is one of the framework's best features. You register a model, and you get a working back office in minutes. Then the tables grow, and the same admin that felt instant starts taking 10, 20, or 40 seconds to load a list page. Some pages time out completely.
The admin is not slow by design. It is slow because it runs the same ORM queries your own code would run, and the defaults are tuned for small tables. This guide walks through the most common causes of slow Django admin pages and how to fix each one, with code you can adapt today.
Step 1: Find Out What Is Actually Slow
Do not guess. Install django-debug-toolbar in development and open the slow admin page. Look at three things:
- Total query count. Hundreds of queries on one list page point to an N+1 problem.
- Duplicate queries. The same query repeated many times is a strong hint.
- Slowest individual queries. A single query taking seconds usually means a missing index or an expensive
COUNTorDISTINCT.
For a query you suspect, run it through the database:
print(Order.objects.filter(status="paid").explain(analyze=True))
In production, enable slow query logging in PostgreSQL (log_min_duration_statement) so you catch what real data produces, not just what your test database does.
Step 2: Fix N+1 Queries in list_display
This is the most common cause. Say your admin looks like this:
@admin.register(Order)
class OrderAdmin(admin.ModelAdmin):
list_display = ("id", "customer", "status", "total")
Displaying customer means Django calls str(order.customer) for each row, which runs one extra query per row. With the default 100 rows per page, that is 101 queries.
Fix it with list_select_related:
@admin.register(Order)
class OrderAdmin(admin.ModelAdmin):
list_display = ("id", "customer", "status", "total")
list_select_related = ("customer",)
For more control, override get_queryset():
Free tech newsletter
FreeFree to join — no card, no paywall. Weekly AI, cloud & tooling insights.
def get_queryset(self, request):
return (
super().get_queryset(request)
.select_related("customer")
.only("id", "status", "total", "customer__name")
)
Be careful with only() here. If a list_display method or __str__ reads a field you did not include, Django runs an extra query per row and you are back to N+1. Test the page after any change.
Step 3: Stop Running Queries Inside Custom Columns
Custom display methods are a quiet performance killer:
def item_count(self, obj):
return obj.items.count() # one query per row
Move the calculation into the queryset with annotate(), and expose it as a sortable column:
from django.db.models import Count
def get_queryset(self, request):
return super().get_queryset(request).annotate(_item_count=Count("items"))
@admin.display(ordering="_item_count", description="Items")
def item_count(self, obj):
return obj._item_count
Now the database computes the count once for the whole page instead of once per row.
Step 4: Speed Up Pagination and the Result Count
On a table with millions of rows, the admin runs COUNT(*) queries to show "1,234,567 total" and to build the paginator. In PostgreSQL, counting a large table can be slow.
Two settings help:
class OrderAdmin(admin.ModelAdmin):
list_per_page = 50
show_full_result_count = False
show_full_result_count = False skips the extra count of the entire unfiltered table, which is an easy win. If the main count is still too slow, a custom paginator that caches the count or uses an estimate is the next step. Keep in mind that an estimate means the page numbers may be slightly off, which is usually acceptable for an internal tool.
Also make sure the column used for ordering is indexed. A default ordering on an unindexed column forces the database to sort the entire table.
Step 5: Tame Filters and the Date Hierarchy
Every list_filter entry can run its own query to build its choices, and some run SELECT DISTINCT over the full table.
Free tech newsletter
FreeFree to join — no card, no paywall. Weekly AI, cloud & tooling insights.
- Remove filters nobody uses.
- Avoid filtering by fields with thousands of distinct values, such as a foreign key to a large table.
- Replace expensive filters with a custom
SimpleListFilterthat offers a short, fixed list of choices. - Avoid
date_hierarchyon huge tables unless the date column is indexed, because it runs several aggregate queries.
Since Django 5.0, the admin can also show facet counts next to each filter option. They are computed only when the user toggles them on, which is a sensible default. Avoid forcing them to always show on very large tables, because each count is another query.
Step 6: Make Search Cheap
By default, search_fields uses icontains, which becomes LIKE '%term%'. A normal B-tree index cannot help with a leading wildcard, so every search scans the whole table.
You can change the lookup with a prefix on the field name:
search_fields = ("^email", "=order_number", "name")
^emailuses a "starts with" match, which can use an index.=order_numberuses an exact match, which is the fastest.- A plain name keeps
icontains.
For real text search on large tables, consider PostgreSQL trigram indexes (pg_trgm) or full-text search. Also remember that searching across relations, such as customer__name, adds JOINs, so keep that list short.
Step 7: Replace Giant Dropdowns
A foreign key field renders as a <select> containing every row of the related table. If that table has 200,000 customers, the edit page loads 200,000 options into the HTML. That is slow on the server and slow in the browser.
Use one of these instead:
class OrderAdmin(admin.ModelAdmin):
autocomplete_fields = ("customer",) # search box, loads results on demand
# or
raw_id_fields = ("customer",) # plain ID input with a lookup popup
autocomplete_fields is friendlier, but the related model's admin must define search_fields. raw_id_fields is the simplest and lightest. Both remove the giant dropdown.
Step 8: Keep Inlines Under Control
Inlines can multiply your query count, especially when each inline row has its own foreign key dropdown.
class OrderItemInline(admin.TabularInline):
model = OrderItem
extra = 0
max_num = 50
raw_id_fields = ("product",)
show_change_link = True
- Set
extra = 0so you do not render empty forms nobody asked for. - Limit
max_num, and useraw_id_fieldsorautocomplete_fieldsfor foreign keys inside the inline. - If a parent object can have thousands of children, do not show them as an inline at all. Use
show_change_linkor link to a filtered list of the child model instead. - Override
get_queryset()on the inline to useselect_related()when the inline displays related fields.
Step 9: Be Careful with Admin Actions
Built-in bulk actions can surprise you on large selections. The default "delete selected" action collects related objects before deleting, which can mean a lot of queries and memory for big selections. For heavy operations, avoid running them inside the request. Queue them as background tasks and show a message that the job has started.
Free tech newsletter
FreeFree to join — no card, no paywall. Weekly AI, cloud & tooling insights.
The same applies to export actions. Building a CSV of an entire table in a normal admin response is a classic way to hit memory limits and timeouts, so stream it or hand it to a background job.
A Quick Admin Performance Checklist
Before blaming the database or the server, confirm that:
- You measured query counts with Debug Toolbar
list_select_relatedorget_queryset()handles foreign keys inlist_display- No
list_displaymethod runs a query per row show_full_result_count = Falseis set on large tables- Filters,
date_hierarchy, andsearch_fieldsare limited and indexed - Foreign keys use
autocomplete_fieldsorraw_id_fields - Inlines have
extra = 0and a sensiblemax_num - Heavy actions and exports run in the background or stream their output
Related Reading: Go Deeper on Each Problem
Slow admin pages usually come from the same few ORM habits that slow down the rest of your application. If one of the problems above sounds familiar, these guides cover it in more depth:
- If your list page runs hundreds of queries: select_related vs prefetch_related: Practical Examples explains exactly when to use each, which also tells you how to build the right
get_queryset()for your admin. - If your tables are in the millions of rows: Django ORM Optimization for Millions of Records covers indexes,
EXPLAIN ANALYZE, and keyset pagination, which are the fixes behind most slow counts, searches, and sorts. - If your admin export action keeps timing out: How to Export Large CSV Files in Django Without Memory Errors shows how to stream big exports instead of building them in memory.
- If a bulk action needs to touch millions of rows: How to Process 5 Million Records with Django and Celery shows how to split the work into chunks and run it in the background instead of inside an admin request.
Conclusion
A slow Django admin is almost never a mystery once you measure it. The usual culprits are N+1 queries in list_display, expensive counts, unindexed searches and filters, giant dropdowns, and heavy inlines or actions. Fix them one at a time, check the query count after each change, and the same admin that crawled on a million rows will feel quick again.
Treat the admin like any other part of your application. It deserves the same indexes, query discipline, and background processing as your public pages.
Frequently Asked Questions
Why is my Django admin list page so slow?
The most common causes are N+1 queries from foreign keys in list_display, slow COUNT(*) queries on large tables, unindexed search and filter fields, and custom columns that run a query for every row. Measure with Django Debug Toolbar to see which applies.
What does list_select_related do in Django admin? It tells the admin to load the listed foreign key relations with a SQL JOIN in the main query, so displaying related objects does not trigger one extra query per row.
How do I make Django admin search faster?
Use ^ for starts-with or = for exact matches in search_fields instead of the default icontains, keep the list of searched fields short, and use trigram or full-text indexes in PostgreSQL for real text search on large tables.
What is the difference between autocomplete_fields and raw_id_fields?
Both avoid rendering a huge dropdown. autocomplete_fields gives a search box that loads matching results on demand and requires search_fields on the related admin. raw_id_fields shows a simple ID input with a lookup popup and is the lightest option.
Should I disable show_full_result_count? On large tables, yes. It removes an extra count of the entire unfiltered table, which is often one of the slowest queries on the page.
