How debug Django queryset with explain() function
When building applications with Django, the ORM provides an excellent abstraction layer over your database. However, this convenience can sometimes mask inefficient queries that degrade performance as your dataset grows.
Enter the explain() method. Added in Django 2.1, explain() is a powerful QuerySet function that allows developers to peek under the hood and see exactly how the database intends to execute a query.
In this article, we'll explore the purpose of explain(), how to use it for debugging, and how to interpret its output for both PostgreSQL and SQLite. Finally, we'll write a handy Pytest fixture to automate performance checks in your test suite.
The Example Models
To illustrate database impact, let's use a classic relationship: Author and Book. In Django, creating a ForeignKey automatically creates a database index for that column to optimize lookups.
from django.db import models
class Author(models.Model):
name = models.CharField(max_length=255)
# No index on 'name' by default
class Book(models.Model):
title = models.CharField(max_length=255) # No index
author = models.ForeignKey(Author, on_delete=models.CASCADE, related_name="books")
# 'author_id' has a database index automatically created by Django
The Database Impact
When you filter by author_id (a Foreign Key), the database uses a B-Tree index to find the records almost instantly. Conversely, filtering by title without an index forces the database to read every single row in the table (a full table scan), which becomes disastrous for performance on large tables.
What is the Purpose of explain() and How to Use It?
The purpose of explain() is to return the Query Execution Plan generated by the database engine. Instead of executing the query to fetch rows, the database calculates the most efficient way to retrieve the data (e.g., using indexes, scanning tables, performing joins) and returns that strategy as a string.
How to Use it in Debugging
During debugging, you can simply append .explain() to any evaluated QuerySet and print it.
# A simple query filtering by an unindexed field
queryset = Book.objects.filter(title="The Great Gatsby")
# Print the execution plan
print(queryset.explain())
For databases like PostgreSQL, you can pass specific options like analyze=True to actually execute the query and get real execution times, or verbose=True for more detailed information:
print(queryset.explain(analyze=True, costs=True))
Examples and Output: PostgreSQL vs. SQLite
1. PostgreSQL Output
Let's look at the output of a bad query (filtering on an unindexed column) versus a good query (filtering on an indexed foreign key).
Query: print(Book.objects.filter(title="1984").explain(analyze=True))
Seq Scan on myapp_book (cost=0.00..25.88 rows=1 width=43) (actual time=0.045..1.230 rows=1 loops=1)
Filter: ((title)::text = '1984'::text)
Rows Removed by Filter: 1250
Execution Time: 1.250 ms
Query: print(Book.objects.filter(author__id=5).explain(analyze=True))
Index Scan using myapp_book_author_id_idx on myapp_book (cost=0.28..8.29 rows=5 width=43) (actual time=0.010..0.015 rows=5 loops=1)
Index Cond: (author_id = 5)
Execution Time: 0.025 ms
2. SQLite Output
SQLite's EXPLAIN QUERY PLAN is simpler and doesn't output costs or execution times by default, but it explicitly tells you if it's scanning or searching.
Query: print(Book.objects.filter(title="1984").explain())
SCAN TABLE myapp_book
Query: print(Book.objects.filter(author__id=5).explain())
SEARCH TABLE myapp_book USING INDEX myapp_book_author_id_idx (author_id=?)
Understanding the explain() Output
While Django returns the output as a formatted string, we can break down the metrics and components (often referred to as "columns" or nodes in database execution plans) into a table to understand exactly what the database is telling us.
| Component / "Column" | Database | Meaning |
|---|---|---|
| Node Type / Method | Both | The operation the database is performing (e.g., Seq Scan, Index Scan, SCAN TABLE, SEARCH TABLE). |
| cost=X..Y | PostgreSQL | Estimated startup cost (X) and total cost (Y) to return all rows. This is an arbitrary unit used by Postgres to pick the best plan. |
| rows=N | PostgreSQL | The estimated number of rows this operation will output. |
| width=W | PostgreSQL | The estimated average width (in bytes) of the rows output by this operation. |
| actual time=X..Y | PostgreSQL | (Requires analyze=True) The actual time in milliseconds spent starting up (X) and executing (Y) the node. |
| loops=N | PostgreSQL | (Requires analyze=True) How many times this specific node was executed during the query (common in nested loop joins). |
| USING INDEX / B-TREE | SQLite / PG | Indicates that a specific index was used to find the data, avoiding a full table scan. |
| Filter / Index Cond | PostgreSQL | The exact SQL condition being applied to filter the rows at this specific node. |
| Rows Removed by Filter | PostgreSQL | The number of rows the database had to read and then discard because they didn't match the condition. High numbers here indicate poor performance. |
Good vs. Bad Execution Plans
When reading an explain plan, certain keywords are massive red flags, while others mean your database is fully optimized.
| Status | Output Keyword / Concept | Meaning & Database Impact |
|---|---|---|
| ❌ | Seq Scan (PostgreSQL) | The database is reading the entire table row-by-row. Terrible for large tables. |
| ❌ | SCAN TABLE (SQLite) | Same as above. A full table scan is occurring because no index could be utilized. |
| ❌ | High Rows Removed by Filter |
The database read a lot of data just to throw it away. You likely need an index on the filtered column. |
| ❌ | Nested Loop (without indexes) | Joining two tables by looping through every row of Table A and comparing it to every row of Table B. Extremely slow ($O(N*M)$ complexity). |
| ✅ | Index Scan / Index Only Scan | The database successfully used an index B-Tree to instantly locate the rows. Highly efficient. |
| ✅ | SEARCH TABLE (SQLite) | The query is efficiently searching using an index or primary key rather than scanning. |
| ✅ | Bitmap Heap Scan (PG) | Good for fetching multiple rows matching an index. It gathers pointers from the index and fetches them in an optimal physical order. |
| ✅ | Hash Join | Typically a very efficient way to join two large sets of data, assuming enough memory is available. |
It is common to confuse
SCAN TABLEwithSCAN ... USING INDEX:
- ❌
SCAN TABLE(BAD): A full table scan. The database reads every single row on disk from start to finish.- ✅
SCAN ... USING INDEX(GOOD): An Index Scan. The database traverses your pre-filtered index.
Automating Optimization: A Pytest Fixture
You shouldn't have to manually print .explain() every time you write a new feature. Instead, you can create a Pytest fixture that analyzes a QuerySet and automatically fails the test if it detects a full table scan.
Here is a pytest fixture you can drop into your conftest.py for general use:
import pytest
from django.db import connection
@pytest.fixture
def assert_efficient_query():
"""
A Pytest fixture that asserts a given Django QuerySet
does not perform a full table scan.
"""
def _check_query(queryset):
# Generate the explain plan
plan = queryset.explain()
# Determine the database engine
vendor = connection.vendor
# Define the bad keywords that indicate a full table scan
bad_keywords = []
if vendor == 'postgresql':
bad_keywords = ['Seq Scan']
elif vendor == 'sqlite':
bad_keywords = ['SCAN TABLE']
# Add mysql/oracle handling if needed
# Check the plan
for keyword in bad_keywords:
assert keyword not in plan, (
f"Inefficient query detected! '{keyword}' found in execution plan.\n"
f"Consider adding an index or using select_related/prefetch_related.\n"
f"Explain Plan:\n{plan}"
)
return _check_query
Using the Fixture in a Test
Now, you can use this fixture in your test files to enforce database best practices:
import pytest
from .models import Author, Book
@pytest.mark.django_db
def test_book_queries_are_efficient(assert_efficient_query):
# Setup some test data
author = Author.objects.create(name="George Orwell")
Book.objects.create(title="1984", author=author)
# ❌ This will FAIL the test because `title` is not indexed!
# bad_queryset = Book.objects.filter(title="1984")
# assert_efficient_query(bad_queryset)
# ✅ This will PASS because `author_id` is a Foreign Key (Indexed by default)
good_queryset = Book.objects.filter(author=author)
assert_efficient_query(good_queryset)
By leveraging explain() proactively, you can catch missing indexes, N+1 query problems, and heavy database scans before they ever make it into your production environment!