Opened 10 months ago

Last modified 10 months ago

#28586 new New feature

Automatically prefetch related for "to one" fields as needed.

Reported by: Gordon Wrigley Owned by: nobody
Component: Database layer (models, ORM) Version: master
Severity: Normal Keywords: prefetch_related
Cc: Adam (Chainz) Johnson, Ryan Hiebert, Ed Morley, Jonas Haag Triage Stage: Accepted
Has patch: yes Needs documentation: yes
Needs tests: no Patch needs improvement: no
Easy pickings: no UI/UX: no

Description (last modified by Gordon Wrigley)

When accessing a 2one field (foreign key in the forward direction and one2one in either direction) on a model instance, if the field's value has not yet been loaded then Django should prefetch the field for all model instances loaded by the same queryset as the current model instance.

There has been some discussion of this on the mailing list!topic/django-developers/EplZGj-ejvg

Currently when accessing an uncached 2one field, Django will automatically fetch the missing value from the Database. When this occurs in a loop it creates 1+N query problems. Consider the following snippet:

for choice in Choice.objects.all():
    print(choice.question.question_text, ':', choice.choice_text)

This will do one query for the choices and then one query per choice to get that choice's question.
This behavior can be avoided with correct application of prefetch_related like this:

for choice in Choice.objects.prefetch_related('question'):
    print(choice.question.question_text, ':', choice.choice_text)

This has several usability issues, notably:

  • Less experienced users are generally not aware that it's necessary.
  • Cosmetic seeming changes to things like templates can change the fields that should be prefetched.
  • Related to that the code that requires the prefetch_related (template for example) may be quite removed from where the prefetch_related needs to be applied (view for example).
  • Subsequently finding where prefetch_related calls are missing is non trivial and needs to be done on an ongoing basis.
  • Excess fields in prefetch_related calls are even harder to find and result in unnecessary database queries.
  • It is very difficult for libraries like the admin and Django Rest Framework to automatically generate correct prefetch_related clauses.

The proposal is on the first iteration of the loop in the example above, when we first access a choice's question field, instead of fetching the question for just that choice, speculatively fetch the questions for all the choices returned by the queryset.
This change results in the first snippet having the same database behavior as the second while reducing or eliminating all of the noted usability issues.

Some important points:

  • 2many fields are not changed at all by this proposal as I can't think of a reasonable way of deciding which of the many to fetch.
  • Because these are 2one fields the generated queries can't have more result rows than the original query and may have less. This eliminates any concern about a multiplicative query size explosion.
  • This feature will never result in more database queries as a prefetch will only be issued where the ORM was already going to fetch a related object.
  • Because it is triggered by fetching missing related objects it will not at all change the DB behavior of code which is full covered by prefetch_related (and select_related) calls.
  • This will inherently chain across relations like, the conditions above still hold under such chaining.
  • It may result in larger data transfer between the database and Django in some situations.

An example of that last point is:

qs = Choice.objects.all() 

Such examples generally seem to be rarer and more likely to be visible during code inspection (vs {{choice.question}} in a template). And larger queries are usually a better failure mode than producing hundreds of queries.
For this to actually produce inferior behavior in practice you need to:

  1. fetch a large number of choices
  2. filter out basically all of them
  3. in a way that prevents garbage collection of the unfiltered ones

If any of those aren't true then automatic prefetching will still produce equivalent or better database behavior than without.

Several optin/optout options were discussed in the mailing list, I will attempt to summarize these below. Most of them are compatible with each other, however in the interests of having a clean interface we probably want to limit how many we implement.

  1. A global option in settings. So as to not accidentally fix existing code this could default to disabled if not specified.
  2. Per queryset either as auto_prefetch_related(value) or prefetch_related(auto=value) where value would determine enabled, disabled, default.
  3. Per object, similar to the per queryset version.
  4. Per model in meta, it's not clear if this was intended to be on
    1. the model used in the original queryset
    2. the model the field is on
    3. the model the field refers to
  5. As a context manager (this could then easily be applied in middleware or a view decorator)
  6. On the field, similar to on_delete

P.S. I've been using this in my own code with no optin / optout for sometime and have had literally no problems with it.

Change History (9)

comment:1 Changed 10 months ago by Gordon Wrigley

I hope to have a first version of a pull for this up tomorrow

comment:2 Changed 10 months ago by Adam (Chainz) Johnson

Cc: Adam (Chainz) Johnson added

comment:3 Changed 10 months ago by Gordon Wrigley

Since there was some discussion over optin / optout strategies I have for the moment gone with one that seems safe and easy to implement. So currently the feature is off by default and enabled by calling auto_prefetch_related() on a queryset.

Related to that I have not addressed documentation at all.

comment:4 Changed 10 months ago by Gordon Wrigley

For curiosity sake I tried running the test suite with auto_prefetch_related enabled by default. There were 3 test failures, two were looking for queries that are removed by auto_prefetch_related.
The third (SwappableModelTests.test_generated_data) attempts to fetch more rows than the sqlite backend can handle in a single 'in' clause, which I'd think is an issue with the 'in' implementation.
Looking at the test it is currently unintentionally doing some four and a quarter thousand DB queries. And attempting to fix it with an explicit prefetch fails in the exact same manner as the automatic prefetch.

Last edited 10 months ago by Gordon Wrigley (previous) (diff)

comment:5 Changed 10 months ago by Ryan Hiebert

Cc: Ryan Hiebert added

comment:6 Changed 10 months ago by Gordon Wrigley

Description: modified (diff)

comment:7 Changed 10 months ago by Ed Morley

Cc: Ed Morley added

comment:8 Changed 10 months ago by Jonas Haag

Cc: Jonas Haag added

comment:9 Changed 10 months ago by Tim Graham

Has patch: set
Needs documentation: set
Triage Stage: UnreviewedAccepted
Note: See TracTickets for help on using tickets.
Back to Top