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:
- How do we identify a slow query in the first place?
- 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 onUPDATEorDELETEqueries, 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").

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.
- The first number (
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 Scanis used to find all matching rows in the index. Then, aBitmap Heap Scanretrieves 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)
- What is the performance bottleneck indicated by this plan?
- What specific change would you make to your
Postmodel inmodels.pyto fix it?
Show answer
-
The Bottleneck: The plan shows a
Seq Scan(Sequential Scan) on thepost_posttable. This means the database is reading every single row to find the ones wherestatusis 'published'. On a large table, this is very slow and inefficient. The high total cost (25835.50) confirms this. -
The Fix: You should add an index to the
statuscolumn. This will allow the database to quickly look up all the 'published' posts without scanning the whole table.You would modify the
Postmodel 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 makemigrationsandpython manage.py migrateto apply the index to the database. The new execution plan would then show anIndex ScanorBitmap 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. Useanalyze=Trueto 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 Scanon a large table for a filtered query. - The goal is to add the right indexes to transform slow
Seq Scanoperations into fastIndex Scanoperations, which you can verify by re-runningexplain().
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.