Reiteach Aigean Logo
Reiteach Aigeanblog.reiteachaigean.com
Admin
HomeAI UpdatesCodingCloudApps & ToolsTech NewsArticles

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.

A
Admin
Published on November 12, 2026
How to Fix Slow Django Admin Pages (Step-by-Step Guide)

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 COUNT or DISTINCT.

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

Free

Free 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

Free

Free 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 SimpleListFilter that offers a short, fixed list of choices.
  • Avoid date_hierarchy on 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")
  • ^email uses a "starts with" match, which can use an index.
  • =order_number uses 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 = 0 so you do not render empty forms nobody asked for.
  • Limit max_num, and use raw_id_fields or autocomplete_fields for 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_link or link to a filtered list of the child model instead.
  • Override get_queryset() on the inline to use select_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

Free

Free 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_related or get_queryset() handles foreign keys in list_display
  • No list_display method runs a query per row
  • show_full_result_count = False is set on large tables
  • Filters, date_hierarchy, and search_fields are limited and indexed
  • Foreign keys use autocomplete_fields or raw_id_fields
  • Inlines have extra = 0 and a sensible max_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:

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.

Share this article

Pass this technical dispatch along to fellow engineers

Did you enjoy this article?

Rate it so we know what content to bring next

Comments (0)