Can not optimise "N+1 Queries" problem with prefetch_related()

Hi there,

I’ve been trying to solve “N+1 Queries” problem as suggested by “Django for Professionals” book, by using prefetch_related().

I suspect that the issue is related to the fact that I introduced an additional “uuid” field in order to securely display URLs for my book objects, and I am simultaneously using a standard auto-increment “id” for foreign-key relationship whereas Django book’s author substituted standard “id” with “uuid”.

I made an experiment - while keeping an additional “uuid” field, I switched back to showing URL’s with simple “id”, and in this case there’s no “N+1 Queries” problem - I have only 5 queries to display the book and associated reviews. When I am using uuid-based URL, I’ve got 14 queries - and 10 of them are just duplicates, and prefetch_related() does not help to solve it.

I read Django’s documentation, googled here and on StackOverFlow, tried to introduce queryset into get_object() method, tried to use combination of select_related and prefetch_related, - nothing really helps. It might also be the case that the cached results of prefetch_related are simply deleted as I’m using “uuid” in get_object(), but I can’t figure out how to solve it. So any help/ideas are appreciated.

Here’s my code and debug-toolbar’s log:

Models:

class Book(models.Model):
    uuid = models.UUIDField(default=uuid.uuid4, editable=False, unique=True)
    title = models.CharField(max_length=200)
    author = models.CharField(max_length=200)
    price = models.DecimalField(max_digits=6, decimal_places=2)
    cover = models.ImageField(upload_to="covers/", blank=True)

    class Meta:
        permissions = [
            ("special_status", "Can read all books"),
        ]

    def __str__(self):
        return self.title

    def get_absolute_url(self):
        return reverse("book_detail", args=[str(self.uuid)])


class Review(models.Model):
    book = models.ForeignKey(
        Book,
        on_delete=models.CASCADE,
        related_name="reviews",
    )
    review = models.CharField(max_length=255)
    author = models.ForeignKey(
        get_user_model(),
        on_delete=models.CASCADE,
    )

    def __str__(self):
        return self.review

Views:

class BookListView(LoginRequiredMixin, ListView):
    model = Book
    context_object_name = "book_list"
    template_name = "books/book_list.html"
    login_url = "account_login"

class BookDetailView(LoginRequiredMixin, PermissionRequiredMixin, DetailView):
    model = Book
    context_object_name = "book"
    template_name = "books/book_detail.html"
    login_url = "account_login"
    permission_required = "books.special_status"

    queryset = Book.objects.select_related("reviews").prefetch_related(
        "reviews__author_id",
    )

    def get_object(self, queryset=None):
        return Book.objects.get(uuid=self.kwargs.get("uuid"))

URL:

urlpatterns = [
    path("", BookListView.as_view(), name="book_list"),
    path("<uuid:uuid>/", BookDetailView.as_view(), name="book_detail"),
    path("search/", SearchResultsListView.as_view(), name="search_results"),
]

Template:

{% block content %}
    <div class="book-detail">
        {% if book.cover %}
            <img class="bookcover" src="{{ book.cover.url }}" alt="{{ book.title }}">
        {% endif %}
        <h2><a href="">{{ book.title }}</a></h2>
        <p>Author: {{ book.author }}</p>
        <p>Price: {{ book.price }}</p>
        <div>
            <h3>Reviews</h3>
            <ul>
                {% for review in book.reviews.all %}
                <li>{{ review.review }} (by {{ review.author }})</li>
                {% endfor %}
            </ul>
        </div>
    </div>
{% endblock content %}

debug-toolbar evidence of “N+1 Queries” problem:

**SELECT "books_book"."id",
       "books_book"."uuid",
       "books_book"."title",
       "books_book"."author",
       "books_book"."price",
       "books_book"."cover"
  FROM "books_book"
 WHERE "books_book"."uuid" = 'ca65fdd62bc84d2eadee92a930b53b6c'::uuid
 LIMIT 21 0.52
/code/books/views.py in get_object(27)
  return Book.objects.get(uuid=self.kwargs.get("uuid"))

+ **SELECT "books_review"."id",
       "books_review"."book_id",
       "books_review"."review",
       "books_review"."author_id"
  FROM "books_review"
 WHERE "books_review"."book_id" = 1 0.40
{% for review in book.reviews.all %}

+ **SELECT "accounts_customuser"."id",
       "accounts_customuser"."password",
       "accounts_customuser"."last_login",
       "accounts_customuser"."is_superuser",
       "accounts_customuser"."username",
       "accounts_customuser"."first_name",
       "accounts_customuser"."last_name",
       "accounts_customuser"."email",
       "accounts_customuser"."is_staff",
       "accounts_customuser"."is_active",
       "accounts_customuser"."date_joined"
  FROM "accounts_customuser"
 WHERE "accounts_customuser"."id" = 2
 LIMIT 21 11 similar queries.  Duplicated 10 times.
<li>{{ review.review }} (by {{ review.author }})</li>

**11 similar queries.** **Duplicated 10 times.** 0.27
+ **SELECT** ••• **FROM** "accounts_customuser" **WHERE** "accounts_customuser"."id" = 2 **LIMIT** 21

**11 similar queries.** **Duplicated 10 times.** 0.23
+ **SELECT** ••• **FROM** "accounts_customuser" **WHERE** "accounts_customuser"."id" = 2 **LIMIT** 21

**11 similar queries.** **Duplicated 10 times.**
..........

Side note: When posting code here, enclose the code between lines of three backtick - ` characters. This means you’ll have a line of ```, then your code, then another line of ```. This forces the forum software to keep your code properly formatted. (I’ve taken the liberty of fixing your original post for you.)

1 Like

What happens if you replace this:

with this?

def get_object(self, queryset=None):
        return Book.objects.filter(uuid=self.kwargs.get("uuid")).prefetch_related("reviews__author").first()
2 Likes

Hi, there are two or three problems with your detail view:

First, you redefine get_object with a specific queryset, so the queryset class attribute is not used at all, hence you do not benefit select_related/prefetch_related optimizations.

Second, instead of redefining get_object, you’d rather use the DetailView class attributes aimed at defining the name of model field identifying object and corresponding url parameter:

class BookDetailView(DetailView):
    slug_url_kwarg = "uuid"
    slug_field = "uuid"

Third, the select_related in queryset class attribute is incorrect as it does not refer to a foreign key on Book model.

I would suggest replacing the queryset class attribute either with

queryset = Book.objects.prefetch_related("reviews__author")

or, even better to avoid one more query

queryset = Book.objects.prefetch_related(Prefetch("reviews", queryset=Review.objects.select_related("author")))
1 Like

Thanks, now it works as it should!!

Actually I tried to do a similar thing but keeping .get() method instead of .filter(), there was an error, so I decided that prefetch_related() works only for .all() - it seemed to imply from Django’s documentation. Hmm, I should have read better and experiment more :slight_smile:

General remark - I have never thought that Django’s ORM could be so tangled and complicated! I do have some experience with SQL, though sometimes I struggle to understand the ideas behind all the ORM’s methods, objects and concepts - I had to basically restrain myself from using .raw() and writing pure SQL :))

Thanks for your comments, I will try to play with them!

I am still struggling with Django’s ORM despite some previous experience with SQL :slight_smile:

In general, when you’re looking at ORM functions, they fall into two broad categories.

Functions that return a queryset, and functions that don’t.

Functions that return a queryset can be chained, because the output of that function on a queryset is another queryset.

Functions that don’t return querysets are the end of the chain, because the data type being returned by that function isn’t a queryset.

2 Likes

Thanks again, this option solves the problem as well! Unfortunately, I can’t mark 2 solutions at the same time.

To sum up, for those who will be digging the same problem and reading this thread, “standard” optimisation with prefetch_related(“reviews__author”) only generates the following two queries:

SELECT "books_review"."id",
       "books_review"."book_id",
       "books_review"."review",
       "books_review"."author_id"
  FROM "books_review"
 WHERE "books_review"."book_id" IN (1)

SELECT "accounts_customuser"."id",
       "accounts_customuser"."password",
       "accounts_customuser"."last_login",
       "accounts_customuser"."is_superuser",
       "accounts_customuser"."username",
       "accounts_customuser"."first_name",
       "accounts_customuser"."last_name",
       "accounts_customuser"."email",
       "accounts_customuser"."is_staff",
       "accounts_customuser"."is_active",
       "accounts_customuser"."date_joined"
  FROM "accounts_customuser"
 WHERE "accounts_customuser"."id" IN (2)

whereas a combination of .prefetch_related() with .select_related(), second option proposed by antoinehumbert above, generates just one inner join:

SELECT "books_review"."id",
       "books_review"."book_id",
       "books_review"."review",
       "books_review"."author_id",
       "accounts_customuser"."id",
       "accounts_customuser"."password",
       "accounts_customuser"."last_login",
       "accounts_customuser"."is_superuser",
       "accounts_customuser"."username",
       "accounts_customuser"."first_name",
       "accounts_customuser"."last_name",
       "accounts_customuser"."email",
       "accounts_customuser"."is_staff",
       "accounts_customuser"."is_active",
       "accounts_customuser"."date_joined"
  FROM "books_review"
 INNER JOIN "accounts_customuser"
    ON ("books_review"."author_id" = "accounts_customuser"."id")
 WHERE "books_review"."book_id" IN (1)

In addition, I liked the idea of defining slug_url_kwarg and slug_field, instead of redefining .get_object(), it looks like a more elegant solution to me. Strangely, but I was not able to figure it out from Django’s book and Django’s documentation :slight_smile:

Conclusion: this forum is a valuable tool!