Skip to main content

Welcome to a Grasp lesson

Create a free account to keep studying and create your own study path.

Lesson 55

Mastering Query Optimization with `EXPLAIN()`

Recommended resources

Hello! Welcome back to our journey through advanced database operations.

In our last lesson, we learned how to create database indexes in Django to speed up queries. We saw that adding an index can dramatically improve performance. But that raises two important questions:

  1. How do we identify a slow query in the first place?
  2. How can we be sure that the database is actually using the index we created?

Today's lesson answers these questions. You will learn how to use one of the most powerful diagnostic tools in a backend developer's toolkit: the query execution plan. By the end of this lesson, you will be able to use the explain() method in Django to analyze how PostgreSQL executes a query and pinpoint performance bottlenecks like missing indexes.

This topic should be particularly interesting given your background. The process of a database's query optimizer choosing the "best" execution plan is analogous to optimization problems you've encountered in machine learning, where an algorithm seeks to minimize a cost function. Here, the database is trying to find the most efficient path to your data.

1. What is a Query Execution Plan?

When you write a Django query like Post.objects.filter(status='published'), you are using a declarative language. You're describing what data you want, but not how the database should retrieve it. The database's job is to figure out the most efficient "how."

This "how" is called the query execution plan. It is a step-by-step sequence of operations that the database will perform to satisfy your query. This plan is generated by a component of the database called the query planner or optimizer. The optimizer's goal is to analyze your query and, based on its knowledge of the tables, indexes, and data statistics, generate the plan with the lowest estimated execution cost.

What Is an Execution Plan and How to Find It in PostgreSQL

To understand the fundamentals of why execution plans are necessary and what they represent, let's read the introductory sections of the article 'What Is an Execution Plan and How to Find It in PostgreSQL' from Simple Talk by Redgate.

Please read the first two main sections: 'Why we need an execution plan?' and 'What is an execution plan?'. These sections explain the difference between declarative and imperative languages and describe the process PostgreSQL follows from receiving a SQL query to generating a physical execution plan.

As the article explains, the optimizer creates a tree of physical operations. Our job is to learn how to read this tree to understand what the database is doing.


2. Generating an Execution Plan in Django

While you can generate execution plans using raw SQL, Django provides a convenient method directly on QuerySets: .explain(). This allows you to stay within your Python code and quickly analyze the performance of your ORM queries.

10 Tips to Optimize PostgreSQL Queries in Your Django ...

This blog post from GitGuardian provides a perfect, concise example of how to use the .explain() method in a Django project.

Read the first two sections, 'A good method to iterate fast' and 'Be aware that the production is not like your local machine'. Focus on the code snippet that demonstrates qs.explain(ANALYZE=True) and its output. The second section provides crucial context about why a plan might differ between your local machine and a production server.

As you saw, calling .explain() on a QuerySet returns the raw text output from the PostgreSQL EXPLAIN command. A particularly useful option is analyze=True, which corresponds to EXPLAIN ANALYZE in SQL.

  • YourQuerySet.explain(): The database estimates the cost and returns the plan without running the query. This is very fast.
  • YourQuerySet.explain(analyze=True): The database executes the query and returns the plan along with the actual time it took. This is more accurate for diagnosing real-world performance but should be used with caution on UPDATE or DELETE queries, as it will actually modify your data.

For the rest of this lesson, we will focus on reading and interpreting this output.


3. How to Read an Execution Plan

The output of EXPLAIN can look intimidating at first, but it follows a clear structure. It's a tree of operations, where you read from the most indented nodes (the "leaves") up to the top-level node (the "root").

SQL Query Execution Plan Analysis with EXPLAIN
This image shows how a database tool might visualize an execution plan. The text-based plan we'll be using represents the same tree structure, just with indentation. Notice how the tool highlights a 'Seq Scan' as a high-cost node—this is exactly the kind of bottleneck we're looking for.

Let's break down the key components you'll see in the output.

Postgres Explain Explained - How Databases Prepare Optimal Query Plans to Execute SQL

This video, 'Postgres Explain Explained' by Hussein Nasser, provides an excellent and practical walkthrough of the EXPLAIN command's output. We will watch it in parts to understand the most important concepts.

Watch the first four minutes of the video (00:00 - 03:57). The presenter covers the most fundamental operation, the Sequential Scan, and explains the cost, rows, and width metrics.

Based on the video, here are the key takeaways for reading a plan node:

-> Seq Scan on books_person (cost=0.00..20251.18 rows=1000018 width=8)

  • Operation Type: The first part tells you what the database is doing. The most important ones to know initially are:
    • Seq Scan (Sequential Scan): A full table scan. The database reads every single row. This is often a performance bottleneck on large tables.
    • Index Scan: The database uses an index to look up rows. This is what you want to see for filtered queries on large tables.
    • Index-Only Scan: An even better version where the database finds all the data it needs from the index itself, without even touching the main table.
  • cost=...: This is the planner's estimate of the work required.
    • The first number (0.00) is the startup cost: the estimated cost to retrieve the first row.
    • The second number (20251.18) is the total cost: the estimated cost to retrieve all rows.
    • Important: These are abstract units, not time. Their value is in comparing different plans—a lower cost is better.
  • rows=...: The planner's estimate of the number of rows that will be returned by this operation.
  • width=...: The estimated average size of each row in bytes.

Now let's see how indexes change the plan.

Postgres Explain Explained - How Databases Prepare Optimal Query Plans to Execute SQL

Let's continue with the video to see how filtering on an indexed column changes the execution plan from a 'Seq Scan' to an 'Index Scan'.

Watch from 06:54 to 09:52. Pay close attention to the difference in the plan when the query includes a WHERE id = ... clause. Notice the introduction of Index Scan and Index-Only Scan nodes.

This is the core concept: a slow Seq Scan on a large table can often be transformed into a fast Index Scan by adding the correct index.


4. A Practical Example: Finding the Bottleneck

Let's put this all together. Imagine we're working with the Post model from the previous lesson's exercise. The table has 1 million posts.

from django.db import models
from django.contrib.auth.models import User

class Post(models.Model):
    title = models.CharField(max_length=255)
    content = models.TextField()
    author = models.ForeignKey(User, on_delete=models.CASCADE)
    status = models.CharField(max_length=10, choices=[('draft', 'Draft'), ('published', 'Published')])
    published_at = models.DateTimeField(null=True, blank=True)

Scenario 1: No Index

We want to find all posts by a specific author.

# In Django Shell
from post.models import Post
from django.contrib.auth.models import User

author = User.objects.first()
qs = Post.objects.filter(author=author)

print(qs.explain())

The output would look something like this:

Seq Scan on post_post  (cost=0.00..23336.12 rows=5000 width=120)
  Filter: (author_id = 1)

The key here is Seq Scan. Even though we're only looking for posts by one author, the database has to scan all 1 million rows and apply a filter. The estimated total cost is high (23336.12).

Scenario 2: With an Index

In the last lesson, you learned that Django automatically creates an index on ForeignKey fields. So, in reality, your author field already has an index. Let's see what the plan actually looks like.

The output would be closer to this:

Bitmap Heap Scan on post_post  (cost=85.12..1234.56 rows=5000 width=120)
  Recheck Cond: (author_id = 1)
  ->  Bitmap Index Scan on post_post_author_id_idx  (cost=0.00..84.45)
        Index Cond: (author_id = 1)

(Note: A Bitmap Scan is another type of indexed retrieval that is very efficient. For our purposes, seeing either Index Scan or Bitmap Scan is a good sign!)

Look at the difference!

  • The plan now uses the index (post_post_author_id_idx).
  • The total cost is dramatically lower (1234.56 vs 23336.12).
  • The plan is read from the inside out: First, a Bitmap Index Scan is used to find all matching rows in the index. Then, a Bitmap Heap Scan retrieves those rows from the table itself.

This "before and after" demonstrates the power of using .explain(): you can prove that your index is not only present but is being used by the database to execute your query efficiently.

Test your understanding!

You are investigating a slow API endpoint that displays published posts. The query is Post.objects.filter(status='published'). Your Post table is very large. You run .explain() and get the following plan:

Seq Scan on post_post  (cost=0.00..25835.50 rows=500000 width=120)
  Filter: (status = 'published'::bpchar)
  1. What is the performance bottleneck indicated by this plan?
  2. What specific change would you make to your Post model in models.py to fix it?
Show answer
  1. The Bottleneck: The plan shows a Seq Scan (Sequential Scan) on the post_post table. This means the database is reading every single row to find the ones where status is 'published'. On a large table, this is very slow and inefficient. The high total cost (25835.50) confirms this.

  2. The Fix: You should add an index to the status column. This will allow the database to quickly look up all the 'published' posts without scanning the whole table.

    You would modify the Post model like this:

    # models.py
    from django.db import models
    from django.contrib.auth.models import User
    
    class Post(models.Model):
        title = models.CharField(max_length=255)
        content = models.TextField()
        author = models.ForeignKey(User, on_delete=models.CASCADE)
        status = models.CharField(max_length=10, choices=[('draft', 'Draft'), ('published', 'Published')])
        published_at = models.DateTimeField(null=True, blank=True)
    
        class Meta:
            indexes = [
                models.Index(fields=['status']), # Add this index
            ]
    

    After adding this, you would need to run python manage.py makemigrations and python manage.py migrate to apply the index to the database. The new execution plan would then show an Index Scan or Bitmap Scan, with a much lower cost.


Conclusion

You've now learned how to peek under the hood of the database. By using queryset.explain(), you can stop guessing about query performance and start making data-driven decisions.

Key Takeaways:

  • A Query Execution Plan is the database's step-by-step recipe for retrieving data.
  • Use YourQuerySet.explain() in Django to view the execution plan for a query. Use analyze=True to get actual execution times.
  • Execution plans are trees; read them from the most indented nodes outwards.
  • The number one performance bottleneck to look for is a Seq Scan on a large table for a filtered query.
  • The goal is to add the right indexes to transform slow Seq Scan operations into fast Index Scan operations, which you can verify by re-running explain().

Preview of the Next Lesson:
We've now mastered how to optimize queries that access a single table. However, many real-world performance problems happen when we combine data from multiple tables. A common issue is the "N+1 query problem," where a simple loop in your code can trigger hundreds or even thousands of database queries. In the next lesson, we will learn how to diagnose and solve this problem using two powerful ORM methods: select_related and prefetch_related.