Django cached_property is NOT being cached

I have the following in my models:

class Tag(models.Model):
    name = models.CharField(max_length=255)
    type = models.CharField(max_length=1)
    person = models.ForeignKey(People, on_delete=models.CASCADE)

class People(models.Model):
    name = models.CharField(max_length=255)

    @cached_property
    def tags(self):
        return Tag.objects.filter(person=self, type="A")

I would expect that when I do this:

person = People.objects.get(pk=1)
tags = person.tags

That this would result in 1 db query - only getting the person from the database. However, it continuously results in 2 queries - the tags table is being consistently queried even though this is supposedly cached. What can cause this? Am I not using the cached_property right?

The models are simplified to illustrate this case.

You decorated tags() methods returns a queryset that is not evaluated yet. (Read more about when a queryset is evaluated in Django's documentation). To cache the results of the query you have to force the queryset first to evaluate to a list of objects:

class People(models.Model):
    name = models.CharField(max_length=255)

    @cached_property
    def tags(self):
        return list(Tag.objects.filter(person=self, type="A"))

It's hard to figure out what is wrong without seeing the code that actually calls the cached property multiple times. However, the way you describe your issue, cached_property seems to be the right approach and should work.

My guess is that there might be some misunderstanding about how it works. An example use case for a cached property would be:

person = People.objects.get(pk=1)  # <- Query on People executed
text_tags = ', '.join(person.tags)  # <- Query on Tags executed
html_tags = format_html_join(
    '\n',
    '<span class="tag tag-{}">{}</span>',
    ((t.type, t.name) for t in person.tags),  # <- tags loaded from cache, no query executed
)

However, if you do something like this:

for person in People.objects.all(): # <- Query on People executed
    text_tags = ', '.join(person.tags)  # <- Query on Tags executed FOR EACH ITERATION
    html_tags = format_html_join(
        '\n',
        '<span class="tag tag-{}">{}</span>',
        ((t.type, t.name) for t in person.tags),  # <- tags loaded from cache, no query executed
    )

The first call to person.tags of each iteration of the for loop executes a query. This is because the result of the property tags is cached per instance.

If you want to cache all the tags you need in advance when iterating over people objects, there are several approaches depending on your use case.


The manual approach

from itertools import groupby

all_tags = Tags.objects.filter(type="A").order_by('person_id')
# order_by() is important because we will use person_id as key to group the results using itertools.groupby()

# Create a dictionary matching a person to it's list of tags using a single SQL query
people_tags = {
    person_id: list(tags)
    for person_id, tags in groupby(all_tags, lambda t: t.person_id)
}

for person in People.objects.all():
    # Try to find the person's tags in the dictionary, otherwise, set tags to an empty list
    tags = people_tags.get(person.id, [])

The single query with aggregations approach

For this approach, you will need to make sure your foreign key has a related name, to be able to make "reverse" queries:

class Tag(models.Model):
    name = models.CharField(max_length=255)
    type = models.CharField(max_length=1)
    person = models.ForeignKey(
        People,
        on_delete=models.CASCADE,
        related_name='tags',
    )

Specifying related_name is not strictly required as Django gives a default related name but I can't ever remember how this name is built so I always give it explicitely.

Don't forget to remove the tags() method as the name would clash with the related name "tags".

from django.db.models import Q
from django.contrib.postgres.aggregates import ArrayAgg


persons = (
    People.objects.all()
    .annotate(tags_names=ArrayAgg('tags__name', filter=Q(tags__type='A')))
)
for person in persons:
    tags = person.tags_names

Note that with this approach, person.tags_names will be a list of tag names as strings, not a list of Tag objects. There are some tricky ways to retrieve Tag objects, or at least more than a single field, using annotate() but I think this is beyond the scope of this question.

Also note that this will only work with PostgreSQL.


Django's built-in way: prefetch_related()

Django ships with a prefetch_related() method on QuerySet objects. It is especially designed as a shortcut to the manual approach. This approach requires the use of the foreign key's related_name mentioned above.

from django.db.models import Prefetch

persons = (
    People.objects.all()
    .prefetch_related(
        Prefetch('tags', queryset=Tag.objects.filter(type='A'))
    )
)
for person in persons:
    tags = person.tags

Note that if you don't need to filter the tags by type, you can simply do People.objects.prefetch_related('tags').

Back to Top