Search code examples
djangodjango-templatestemplatetags

Why does the built-in "with" template tag in django result in duplicate queries


In the django docs for the "with" template tag, it says that this tag..

is useful when accessing an “expensive” method (e.g., one that hits the database) multiple times.

However, when I use it in my own template, I've observed (using django debug toolbar) that each time I call the variable that I create using the "with" template tag, it results in duplicate queries to the database if I perform an operation on that variable within another template tag. Why is this the case? Shouldn't I be able to do things to that variable without it querying the database?

Below is my abridged and simplified code.

Background

I have three models:

## models.py
class Contributor(models.Model):
    name = models.CharField(max_length=100)

class Contribution(models.Model):
    contributor = models.ForeignKey(
        Contributor, on_delete=models.RESTRICT, related_name='contributions'
    )
    class ROLE_CHOICES(models.TextChoices):
        A = 'AUTHOR', _('Author')
        C = 'COAUTHOR', _('Co-Author')
        E = 'EDITOR', _('Editor')
        V = 'VOICE', _('Voice Actor')
        S = 'AUDIO', _('Audio Engineer')

    role = models.CharField(max_length=10, choices=ROLE_CHOICES.choices)

class Scenario(models.Model):
    title = models.CharField(max_length=200)
    contributions = models.ManyToManyField(
        Contribution, related_name='contributions'
    )

The template is associated with a class-based detail view:

## views.py
class ScenarioDetailView(generic.DetailView):
    model = Scenario

Here is my custom template tag code:

## custom_tags.py
@register.filter
def only_role(scenario, test_role):
    return scenario.contributions.filter(role=test_role)

Here is my template:

scenario_detail.html

{% load custom_tags %}

<dl>
{% with authors=scenario|only_role:"AUTHOR" %}
  {% if authors.count > 0 %}
    <dt>Author{{ authors.count|pluralize }}</dt>
    {% for contribution in authors %}
      <dd>{{ contribution.contributor.name }}</dd>
    {% endfor %}
  {% endif %}
  {% comment %}The code in the "if" block would be repeated for all the possible roles, and I would add a variable to the "with" template tag for each role.{% endcomment %}
{% endwith %}
</dl>

Problem

Here are the duplicate queries on the django debug toolbar readout:

SELECT COUNT(*) AS "__count" FROM "scenario_contribution" INNER JOIN "scenario_scenario_contributions" ON ("scenario_contribution"."id" = "scenario_scenario_contributions"."contribution_id") WHERE ("scenario_scenario_contributions"."scenario_id" = 'demo' AND "scenario_contribution"."role" = 'AUTHOR')
 2 similar queries.  Duplicated 2 times.        2.76    
Sel Expl

SELECT COUNT(*) AS "__count" FROM "scenario_contribution" INNER JOIN "scenario_scenario_contributions" ON ("scenario_contribution"."id" = "scenario_scenario_contributions"."contribution_id") WHERE ("scenario_scenario_contributions"."scenario_id" = 'demo' AND "scenario_contribution"."role" = 'AUTHOR')
 2 similar queries.  Duplicated 2 times.        0.72    
Sel Expl
    
SELECT ••• FROM "scenario_contribution" INNER JOIN "scenario_scenario_contributions" ON ("scenario_contribution"."id" = "scenario_scenario_contributions"."contribution_id") INNER JOIN "scenario_contributor" ON ("scenario_contribution"."contributor_id" = "scenario_contributor"."id") LEFT OUTER JOIN "scenario_participant" ON ("scenario_contribution"."character_id" = "scenario_participant"."id") WHERE ("scenario_scenario_contributions"."scenario_id" = 'demo' AND "scenario_contribution"."role" = 'AUTHOR') ORDER BY "scenario_contributor"."last_name" ASC, "scenario_contributor"."first_name" ASC, "scenario_contribution"."role" ASC, "scenario_participant"."part_type" ASC, "scenario_participant"."designation" ASC

Do the duplicate queries stem from the fact that I'm using the "count" method on the variable "authors"? I tried the dummy template code below, and it dropped the number of queries by 2, so that would suggest this is the case.

Attempted Solutions

Dummy Template Code

{% with authors=scenario|only_role:"AUTHOR" %}
  {% if authors %}
    <dt>{{ authors }}</dt>
    {% for contribution in authors %}
      <dd>{{ contribution.contributor.first_name }}</dd>
    {% endfor %}
  {% endif %}
{% endwith %}

If so, how might I, for example, return the count information in a single custom tag? Can I return two values in one custom tag, or would I need a separate custom tag? (in other words, have one custom tag that returns a filtered queryset as well as the number of objects). I've written a custom tag that counts the number of objects, and I've gotten the number of queries for this specific block of template down to 2 instead of 3 by using it, but it would be nice to have it down to 1 query.

Count Custom Template Tag

@register.filter
def num_role(scenario, test_role):
    return scenario.contributions.filter(role=test_role).count

Revised Template Code

scenario_detail.html

{% load custom_tags %}

<dl>
{% with authors=scenario|only_role:"AUTHOR" n_authors=scenario|num_role:"AUTHOR" %}
  {% if n_authors > 0 %}
    <dt>Author{{ n_authors|pluralize }}</dt>
    {% for contribution in authors %}
      <dd>{{ contribution.contributor.name }}</dd>
    {% endfor %}
  {% endif %}
  {% comment %}The code in the "if" block would be repeated for all the possible roles, and I would add a variable to the "with" template tag for each role.{% endcomment %}
{% endwith %}
</dl>


Solution

  • Why is this the case? Shouldn't I be able to do things to that variable without it querying the database?

    A QuerySet does not query the database, or at least not instantly when it is constructed. It is basically a promise to query the database when necessary, and .count() thus will query the database. You constructed one QuerySet, but this will make multiple queries.

    A QuerySet can however cache the result if it is evaluated. If you use len(…) it does not count the records, or at least by a query, it loads the records, and then checks how many records have been loaded.

    We can exploit this to work with the |length template filter [Django-doc], which will force evaluation of the QuerySet, and can be reused:

    {% load custom_tags %}
    
    <dl>
    {% with authors=scenario|only_role:"AUTHOR" %}
      {% if authors|length %}
        <dt>Author{{ authors|length|pluralize }}</dt>
        {% for contribution in authors %}
          <dd>{{ contribution.contributor.name }}</dd>
        {% endfor %}
      {% endif %}
    {% endwith %}
    </dl>

    The {% if authors|length %} can also be replaced with {% if authors %}, since a QuerySet has truthiness True if it contains at least one record.