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.

Where admin time goesChangelist countsCOUNT(*) twiceList displayN+1 related lookupsFilters and searchscans, DISTINCTChange formhuge FK dropdownsEach admin default is cheap on small tables and a full scan on large ones.
  1. Measure with the database's slow query log or pg_stat_statements, or Django Debug Toolbar on a production-sized copy.
  2. Set show_full_result_count = False and, for very large tables, use a paginator with an estimated count.
  3. Add list_select_related for foreign keys shown in list_display.
  4. Replace foreign key dropdowns with autocomplete_fields or raw_id_fields.
  5. Restrict search_fields to indexed columns and use = (exact) or ^ (starts-with) prefixes.
  6. Avoid list_filter on 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.

counting 20 million rows on every pageDefault change list✕COUNT(*) for the paginator✕another COUNT(*) for "N total"✕seconds per page on huge tablesTuned change list✓show_full_result_count = False✓estimated-count paginator✓page loads stay fastPostgreSQL has to scan to count; there is no cached totaldjango-deployment.com
pg_class.reltuples gives a fast estimate for unfiltered lists.
@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.

foreign key widgets comparedLoadsSuitsDefault select✕every row into a dropdownsmall lookup tablesraw_id_fields✓nothing: an ID boxinternal toolsautocomplete_fields✓matches as you typestaff-friendly searchA dropdown of a million customers can take the page downdjango-deployment.com
autocomplete_fields needs search_fields on the related admin.
@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") means icontains: 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 example Upper("email").
  • Searching across related fields ("customer__email") adds joins and, for multi-valued relations, a DISTINCT step.

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_filter on a foreign key renders one choice per related row. Filtering orders by customer lists every customer. Use filters on low-cardinality fields (status, boolean, date) or a custom SimpleListFilter with 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
change list options for big tableslist_select_relatedno N+1 in list_displaysearch on indexed fieldsprefix or trigram indexeslist_filter on low-cardinality fieldsno filter on huge FKsordering by an indexed columnor none at allshow_facets offfacet counts cost queriesCheck each change with the query count, not by feeldjango-deployment.com
django-debug-toolbar on staging shows the admin query count per page.
  • date_hierarchy issues date-truncation queries over the whole table; keep it only if the date column is indexed and the queries are fast.
  • The default ordering (ordering on the admin or the model's Meta.ordering) should be backed by an index, typically -created or -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

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.

Keep going