Fix a Slow Django Admin on Large Tables
Speed up Django admin changelists and forms on tables with millions of rows: avoid full counts, add list_select_related, use autocomplete and raw ID fields, index search and ordering, and limit filters.
Problem statement
The Django admin was fast in development and is painfully slow in production. Opening the changelist for orders or events takes 10 to 30 seconds, sometimes ending in a 504; the edit form for a single row takes seconds to load; and searching hangs. The database is busy while staff use the admin, which affects the rest of the site.
The admin's defaults are designed for convenience on modest tables: it counts all rows for pagination, counts them again for the "show all" link, renders foreign keys as dropdowns containing every related row, and searches with LIKE '%term%' across several columns. On a table with millions of rows, each of those becomes a full scan. This guide finds which one is slow and applies the matching setting.
- Measure with the database's slow query log or
pg_stat_statements, or Django Debug Toolbar on a production-sized copy. - Set
show_full_result_count = Falseand, for very large tables, use a paginator with an estimated count. - Add
list_select_relatedfor foreign keys shown inlist_display. - Replace foreign key dropdowns with
autocomplete_fieldsorraw_id_fields. - Restrict
search_fieldsto indexed columns and use=(exact) or^(starts-with) prefixes. - Avoid
list_filteron high-cardinality foreign keys, turn off facet counts, and make default ordering use an index.
Step-by-step solution
1. Measure first
On PostgreSQL, the statements the admin sends appear in pg_stat_statements:
SELECT calls, round(mean_exec_time) AS ms, left(query, 120)
FROM pg_stat_statements
WHERE query ILIKE '%orders_order%'
ORDER BY mean_exec_time DESC LIMIT 10;
Or log slow queries with log_min_duration_statement = 500 and open the changelist once. Typical culprits: SELECT COUNT(*) FROM "orders_order", many small queries on a related table, and UPPER(...) LIKE UPPER('%term%').
2. Stop counting the whole table
The changelist runs COUNT(*) for pagination, and a second count of the unfiltered table for the "N total" link when filters are active. On PostgreSQL, counting a large table means scanning it.
@admin.register(Order)
class OrderAdmin(admin.ModelAdmin):
show_full_result_count = False
This removes the second count. For tables large enough that even one count is slow, estimate it for the unfiltered case:
from django.core.paginator import Paginator
from django.db import connection
from django.utils.functional import cached_property
class EstimatedCountPaginator(Paginator):
@cached_property
def count(self):
query = self.object_list.query
if not query.where:
with connection.cursor() as cursor:
cursor.execute(
"SELECT reltuples::bigint FROM pg_class WHERE oid = %s::regclass",
[query.model._meta.db_table],
)
estimate = cursor.fetchone()[0]
if estimate > 100_000:
return estimate
return super().count
@admin.register(Order)
class OrderAdmin(admin.ModelAdmin):
paginator = EstimatedCountPaginator
show_full_result_count = False
reltuples is maintained by VACUUM and ANALYZE, so it is approximate; staff see "about 12,400,000" instead of an exact number, which is fine for browsing. Filtered queries still count exactly, which is fast when filters use indexes.
3. Avoid N+1 queries in list_display
class OrderAdmin(admin.ModelAdmin):
list_display = ["id", "customer", "status", "created"]
list_select_related = ["customer"]
Without it, each row's customer triggers a separate query (100 rows per page, 100 extra queries). For values computed in methods, annotate the queryset in get_queryset() rather than querying per row.
4. Replace huge foreign key dropdowns
On the change form, a ForeignKey renders as a <select> with every related row. With a million customers, the form builds a million options.
@admin.register(Customer)
class CustomerAdmin(admin.ModelAdmin):
search_fields = ["email"]
@admin.register(Order)
class OrderAdmin(admin.ModelAdmin):
autocomplete_fields = ["customer"]
# or: raw_id_fields = ["customer"]
autocomplete_fields loads matching options through a search request and requires search_fields on the related model's admin. raw_id_fields shows an ID input with a lookup popup. Check inlines too: every inline form with a foreign key dropdown repeats the problem.
5. Make search use indexes
class CustomerAdmin(admin.ModelAdmin):
search_fields = ["=email", "^last_name", "=id"]
- No prefix (
"email") meansicontains:UPPER(email) LIKE UPPER('%term%'), which cannot use a normal B-tree index. ^means starts-with (istartswith), and=means exact (iexact). These can use an index built for the same expression, for exampleUpper("email").- Searching across related fields (
"customer__email") adds joins and, for multi-valued relations, aDISTINCTstep.
For substring search on large text columns, a PostgreSQL trigram index (pg_trgm with a GIN index) makes icontains fast, or override get_search_results() to use full-text search.
6. Tame filters, facets, and ordering
list_filteron a foreign key renders one choice per related row. Filtering orders bycustomerlists every customer. Use filters on low-cardinality fields (status, boolean, date) or a customSimpleListFilterwith a short list of choices.- Django 5.0 and newer can show facet counts next to filter options, which runs extra count queries. Disable them on large models:
class OrderAdmin(admin.ModelAdmin):
show_facets = admin.ShowFacets.NEVER
date_hierarchyissues date-truncation queries over the whole table; keep it only if the date column is indexed and the queries are fast.- The default ordering (
orderingon the admin or the model'sMeta.ordering) should be backed by an index, typically-createdor-id. Sorting by an unindexed column scans and sorts the whole table for every page.
Verification check:
Open the changelist, a filtered view, a search, and a change form with production-sized data, and watch query counts and durations (Debug Toolbar on a production-like copy, or pg_stat_statements in production). Target: a handful of indexed queries per page, each well under a second.
Explanation
The admin generates queries from ModelAdmin options without knowing table sizes. Pagination counts, per-row related lookups, full dropdowns, and unanchored LIKE searches all scale with the number of rows, which is invisible on development data and dominant in production. Each option in this guide swaps a convenience that scales with table size for one that scales with the page size or uses an index.
Edge cases / notes
Staff actions on large querysets
Admin actions receive the selected queryset; "select all" can mean millions of rows. Process them in background tasks rather than in the request.
Read replicas for the admin
Heavy admin browsing can be routed to a replica with a router that checks for admin requests, keeping analytical load off the primary. Writes from the admin must still go to the primary.
When to automate this
Add a test that loads each admin changelist and change form against a fixture with many related rows and asserts a maximum query count. It catches new list_display fields or foreign keys that bring back N+1 queries or giant dropdowns.
Next steps
- Apply the same query fixes outside the admin with Find and Fix N+1 Queries in Django.
- Measure database load with Find Slow Django Queries with pg_stat_statements.
- Protect the admin itself with the Django Admin Security Checklist.
- If admin pages time out at the proxy, see Fix Django 504 Gateway Timeout Behind Nginx.
FAQ
Why is the Django admin changelist slow on big tables?
It counts all rows for pagination (twice when filtered), and related fields or searches may scan the table. Disable the full result count and optimise related lookups and search.
How do I speed up foreign key fields in the admin form?
Use autocomplete_fields or raw_id_fields so the form does not render every related row in a dropdown.
Why is admin search so slow?
Default search uses icontains, which cannot use normal indexes. Use = or ^ prefixes with matching indexes, or a trigram or full-text index.
Can I show an approximate count in the Django admin?
Yes. A custom paginator can return PostgreSQL's reltuples estimate for unfiltered changelists.