Skip to main content

Command Palette

Search for a command to run...

Reducing queries for ForeignKeys in Django admin inlines

Use 93x less queries using patched raw_id_fields and prefetching

Updated
J

Made my first website in Microsoft Word at age 13, when I discovered the "save as html" button. Since then I've journeyed various computing languages, eventually settling for Python. I love diving into the core of my favorite framework - Django - discover and writing about new stuff.

Speed up with raw_id_fields

ForeignKeys in the Django admin show as a select-box (<select>) by default. This will result in a query for every item in the select, only to get the name. This is not a problem if the model you're referencing will always be small. But growing models with significant rows will become a problem soon. Having 3 times the same select-box with 1000 items will result in 3000 queries. It will also make the select unusable.

Instead, you could use the ModelAdmin.raw_id_fields which turns it into an input widget with a search icon. It will only query the name of the selected item:

Prefetching in inlines

raw_id_fields can also be used in ModelAdmin inlines. However, when you have a lot of these inlines on a page, it's gonna hurt. Having 180 items will result in at least that amount of queries, and usually even double or more.

Less queries = faster load, so to cope with this you can use prefetch_related which I wrote about earlier. Apply the same get_queryset technique on the ModelAdmin, this cached prefetch data will also be available to your inlines.

This should reduce the number of queries to just a few for the entire page. But unfortunately there is a caveat...

The bug in ForeignKeyRawIdWidget

Unfortunately, there is currently a bug in the Django issue tracker that prevents the widget used byraw_id_fields in inlines from using the prefetched values.

TL;DR: in the Django code the relatedForeignKeyIdWidget has to do a QuerySet.get() to get the name of the item. By doing so, the already cached prefetch_related values are ignored. In the said bug issue I proposed a solution, but it proved to be changing too much Django code in other places as well to be accepted.

To work around this problem some code needs to be patched, a snippet with a mixin can be found lower on this page. The example below shows how to implement the patched RawIdWidgetAdminMixin in your code. Note that the mixin should precede ModelAdmin to work. Thanks to the mixin and prefetch_related your page will now consume way fewer queries!

class CustomAdmin(RawIdWidgetAdminMixin, ..., ModelAdmin):
    inlines = ('some-inline',)

    def get_queryset(self, request):
        return (
            super().get_queryset(request)
            .prefetch_related('your-inline-model')
        )

The patched ForeignKeyRawIdWidget can be found in this Github Gist I posted below. Perhaps the most important line is 56 where an object is returned (instead of a plain value). This ultimately prevents the get() as on line 30, we can get the attributes from the object instead of having to query them for each item.

The patch cannot be much smaller than this, as one change triggers another down the line. It can probably be smaller by monkey-patching the classes, but I prefer to keep it clean by subclassing.

Benchmark

A change-view page with 180 inline items with relations:

  1. Using no raw_id_fields: 1658 queries

  2. With raw_id_fields and prefetching in Django 3.1: 384 queries

  3. Patched raw_id_fields and prefetching: 18 queries

This proves that if you have a many inline items on a page, the mixin can reduce the amount of queries. Using raw_id_fields already drastically improves queries compared to having select-boxes. The unpatched raw_id_fields still produces an unacceptable high number of queries, because prefetching is not working due to the bug. This number of queries can still cause serious query times, resulting in your page to be very slow.

Maybe in the future this bug will be fixed in Django, so this patch will not be necessary anymore.

A
Ander2y ago

I am having performance issues because of the inlines, I am really curious about your solution, but is it just for Django v4? that way of defining inlines = ["some-inline"] as strings doesn't look familiar, shouldn't that be classes instead?

J

AFAIK this is applies to all Django versions. You are right that they should be classes instead. It might be that as a string it still works as lazy loading, so probably both work. The key of the problem is how the Django internals work in such a way that it creates a lot of extra queries.

Deep into Django

Part 7 of 9

Django offers a great deal of hidden functionality - secret gems - if you know where to look. In this serie I am showing tips and tricks to get the most out of Django.

Up next

Handling Django exceptions in the middleware

Prevent 500 error raised by exceptions by catching them in the middleware

More from this blog

D

Deep into Django

9 posts

I love diving into the core of my favorite framework - Django - discovering and writing about new tech stuff.