Hello! Welcome to the final lesson of our module on advanced database patterns.
In our previous lessons, we've explored the architecture of high-performance databases, covering B-tree indexes, ACID transactions, replication for read scaling, and sharding for write scaling. We now have a solid theoretical foundation for how databases work under the hood and how they scale.
Today, we transition from theory to one of the most critical, hands-on skills for a backend developer: performance tuning. A slow API endpoint is often just a symptom of a slow database query. Your task is to diagnose the root cause and fix it. This lesson will equip you with the primary tool and methodology to do just that.
Our objective is to optimize a set of slow queries on a sample schema using indexing, query rewriting, and EXPLAIN ANALYZE. We will learn how to read a query's execution plan, identify the specific operations that are causing delays, and apply targeted optimizations to resolve them.
Introducing EXPLAIN ANALYZE: Your Database Performance MRI
When a query is slow, you can't just guess what the problem is. You need to ask the database, "How are you executing this query, and where are you spending your time?" In PostgreSQL, the command for this is EXPLAIN ANALYZE.
It's crucial to understand the difference between two related commands:
EXPLAIN: Shows the query plan, which is the sequence of steps the database intends to take to execute your query. It includes cost estimates but doesn't actually run the query.EXPLAIN ANALYZE: First, it executes the query. Then, it shows the query plan alongside the actual, measured performance statistics from that execution. This is what we'll be using, as it tells us what really happened.
A beginners guide to EXPLAIN ANALYZE – Michael Christofides
To start, let's watch this segment from the talk 'A beginners guide to EXPLAIN ANALYZE' by Michael Christofides. It perfectly breaks down the difference between the two commands and introduces how to read the output.
Watch from 02:31 to 07:29. Focus on the distinction between the estimated plan (EXPLAIN) and the actual execution statistics (EXPLAIN ANALYZE). Pay attention to key metrics like planning time, execution time, and the difference between estimated rows and actual rows.
The key takeaway is that EXPLAIN ANALYZE gives you the ground truth. The actual time values are your guide to finding the bottleneck.
A Systematic Approach to Optimization
Optimizing a query is a scientific process, not guesswork. You form a hypothesis based on the execution plan, apply a change, and measure the result.
This flowchart outlines a robust workflow for tackling any slow query.

Let's formalize this into a step-by-step process.
A beginners guide to EXPLAIN ANALYZE – Michael Christofides
The speaker in the previous video, Michael Christofides, outlines a fantastic five-step process for reviewing query plans. Let's study it.
Watch the section from 09:20 to 11:27. This outlines a practical, repeatable process for analyzing a query plan. Take note of the five steps he recommends.
Here's a summary of that excellent advice:
- Use Realistic Data: The query planner makes different choices for a table with 100 rows versus 100 million rows. Always test on a database that reflects the scale of your production environment.
- Get Maximum Information: Use additional options to get more context. The most useful is
BUFFERS.
EXPLAIN (ANALYZE, BUFFERS) SELECT ...
TheBUFFERSoption shows you how many data blocks were read from disk versus found in the cache (shared hit). A high number of blocks read from disk often points to an inefficient query. - Read the Plan Inside-Out: The query plan is a tree structure. Execution starts at the innermost, most indented nodes and works its way out.
- Find the Most Expensive Part: The
actual timereported for each node is inclusive of the time spent in its child nodes. To find the time spent only in the current operation, you must subtract the times of its children. This helps you pinpoint exactly which operation is the true bottleneck. - Look for Optimization Clues: Once you've identified the slowest node, examine it for clues. Is it a
Seq Scan(Sequential Scan)? Is there a large mismatch between estimated and actual rows? Is it a costlySort?
Now, let's put this process into action.
Hands-On Optimization Lab
For our lab, we'll use a simple schema for a blogging platform. Imagine these tables have grown to contain millions of records.
users(id, username, email, created_at)posts(id, author_id, title, content, published_at)(with a foreign keyauthor_idtousers.id)comments(id, post_id, author_id, content, created_at)
Scenario 1: The Missing Index on a WHERE Clause
A user's profile page is loading slowly. You trace the slowness to this query, which fetches all posts by a specific user:
EXPLAIN (ANALYZE, BUFFERS)
SELECT * FROM posts WHERE author_id = 5000;
Without an index on author_id, the output would look something like this:
QUERY PLAN
--------------------------------------------------------------------------------------------------------------------
Seq Scan on posts (cost=0.00..45000.00 rows=10 width=244) (actual time=0.015..350.123 rows=10 loops=1)
Filter: (author_id = 5000)
Rows Removed by Filter: 1999990
Buffers: shared read=40000
Planning Time: 0.100 ms
Execution Time: 350.200 ms
Diagnosis:
- Expensive Node: The entire query time is spent in one node: the
Seq Scan. - Clues:
Seq Scan: This tells us PostgreSQL is reading the entirepoststable from disk, row by row.Rows Removed by Filter: 1999990: It read 2 million rows just to find the 10 we wanted. This is massively inefficient.Buffers: shared read=40000: It had to read 40,000 data blocks from the disk.
This is the classic signature of a missing index.
Fix: Create an index on the column used in the WHERE clause.
CREATE INDEX idx_posts_author_id ON posts (author_id);
Verification: Run the EXPLAIN ANALYZE command again.
QUERY PLAN
--------------------------------------------------------------------------------------------------------------------
Index Scan using idx_posts_author_id on posts (cost=0.42..8.44 rows=10 width=244) (actual time=0.020..0.035 rows=10 loops=1)
Index Cond: (author_id = 5000)
Buffers: shared hit=4
Planning Time: 0.250 ms
Execution Time: 0.050 ms
Analysis of the Fix:
- The plan changed from
Seq ScantoIndex Scan. The database used our new index like a phone book to find the rows forauthor_id = 5000directly. - The
Execution Timedropped from 350ms to 0.05ms. Buffersshowsshared hit=4, meaning it found the required data in just 4 blocks, which were likely already in the cache. No disk reads were needed.
Optimize Your SQL Queries | Postgres Edition
Let's watch a practical demonstration of this exact scenario. This video shows how EXPLAIN ANALYZE reveals the difference between a Seq Scan and an Index Scan.
Watch from 04:16 to 10:43. The video compares a query on an indexed id column (resulting in an Index Scan) with a query on an un-indexed volume_id column (resulting in a Seq Scan). Note the performance difference and the clues in the query plan.
Scenario 2: The Costly ORDER BY
You want to display the 10 most recently published posts on the homepage. The query is:
EXPLAIN (ANALYZE, BUFFERS)
SELECT id, title, published_at FROM posts ORDER BY published_at DESC LIMIT 10;
The initial plan might look like this:
QUERY PLAN
--------------------------------------------------------------------------------------------------------------------
Limit (cost=12000.50..12000.52 rows=10 width=50) (actual time=850.123..850.125 rows=10 loops=1)
Buffers: shared read=40000, temp read=5000 written=5000
-> Sort (cost=12000.50..12500.50 rows=2000000 width=50) (actual time=850.122..850.123 rows=10 loops=1)
Sort Key: published_at DESC
Sort Method: external merge Disk: 400MB
Buffers: shared read=40000, temp read=5000 written=5000
-> Seq Scan on posts (cost=0.00..45000.00 rows=2000000 width=50) (actual time=0.010..350.500 rows=2000000 loops=1)
Buffers: shared read=40000
Planning Time: 0.100 ms
Execution Time: 850.500 ms
Diagnosis:
- Expensive Node: Reading the plan from the inside out, the
Seq Scantakes 350ms. But theSortnode starts at 850ms. The exclusive time for the sort is roughly850ms - 350ms = 500ms. ThisSortis our primary bottleneck. - Clues:
Sort Method: external merge Disk: 400MB: This is a major red flag. It means PostgreSQL didn't have enough memory (work_mem) to sort all 2 million post records. It had to spill the data to disk to perform the sort, which is extremely slow.temp read=5000 written=5000: This confirms the temporary file usage for the on-disk sort.
Fix: The database had to sort because the data wasn't already in the desired order. We can provide the data pre-sorted by creating an appropriate index.
CREATE INDEX idx_posts_published_at_desc ON posts (published_at DESC);
Verification:
QUERY PLAN
--------------------------------------------------------------------------------------------------------------------
Limit (cost=0.42..2.50 rows=10 width=50) (actual time=0.015..0.020 rows=10 loops=1)
Buffers: shared hit=5
-> Index Scan Backward using idx_posts_published_at_desc on posts (cost=0.42..45000.00 rows=2000000 width=50) (actual time=0.014..0.018 rows=10 loops=1)
Buffers: shared hit=5
Planning Time: 0.150 ms
Execution Time: 0.035 ms
Analysis of the Fix:
- The
Sortnode is gone! - The plan now uses an
Index Scan Backward. Since our index is already sortedDESC, the database can just walk the index backward from the end and pick off the first 10 records it finds. This is incredibly efficient. Execution Timedropped from 850ms to 0.035ms.
Scenario 3: Query Rewriting
Sometimes, even with indexes, the way a query is written can confuse the optimizer. Functions on indexed columns are a common culprit.
Consider a case where we store user emails as-is, but our search needs to be case-insensitive.
-- Assume an index exists on the email column:
-- CREATE INDEX idx_users_email ON users(email);
EXPLAIN ANALYZE
SELECT * FROM users WHERE lower(email) = 'some.user@example.com';
The plan will show a Seq Scan, ignoring our index!
QUERY PLAN
-----------------------------------------------------------------------------
Seq Scan on users (cost=0.00..350.00 rows=5 width=120) (actual time=0.01..50.0)
Filter: (lower((email)::text) = 'some.user@example.com'::text)
Diagnosis: The database cannot use the index on email because the query isn't filtering on email—it's filtering on the result of the lower() function applied to email. The index doesn't contain pre-computed lowercase values.
Fix 1: Functional Index. We can create an index on the expression itself.
CREATE INDEX idx_users_lower_email ON users (lower(email));
Now, re-running the EXPLAIN will show an Index Scan using the new functional index. This is the preferred solution if this type of query is common.
Fix 2: Query Rewriting. If you can't add an index, you could rewrite the query to use an operator that can use an index. For case-insensitive comparison, PostgreSQL has the ILIKE operator or the citext extension. However, a functional index is generally the most direct fix for this specific problem.
The key lesson here is that you must write your queries to match your indexes. Avoid applying functions or type casts to the column side of a comparison, as this often prevents an index from being used.
The Importance of Database Statistics
The query planner's decisions are only as good as its information. It relies on internal statistics about your data (e.g., how many distinct values are in a column, what are the most common values). If these statistics are stale, the planner might make a bad choice, like estimating 10 rows will be returned when in reality 1 million will be.
Query Performance Tuning in PostgreSQL: EXPLAIN ANALYZE Guide
This article from EnterpriseDB provides a great example of how out-of-date statistics can lead to a sub-optimal plan and how to fix it.
Read the section 'The Role of Statistics in PostgreSQL Query Performance'. Pay attention to how a simple ANALYZE command updates the statistics and allows the query optimizer to switch from a Seq Scan to a more efficient Index Scan.
PostgreSQL has an autovacuum process that automatically updates statistics, but for heavily modified tables, you may occasionally need to run ANALYZE table_name; manually to ensure the planner has the best possible information.
Conclusion
You have now learned the fundamental workflow for database performance tuning, a skill that moves you from simply using a database to truly engineering a high-performance backend system.
Key Takeaways:
EXPLAIN ANALYZEis your essential diagnostic tool. It shows you the query plan and the actual time spent in each step.- Optimization is a systematic process: Analyze the plan, identify the most expensive node, find clues (like
Seq ScanorSort), apply a fix (usually an index), and verify the improvement. - Missing indexes are the #1 cause of slow queries. A
Seq Scanon a large table with a highly selectiveWHEREclause is a dead giveaway. ORDER BYcan be very costly. If you sort frequently on a column, an index on that column can eliminate the need for an expensiveSortoperation.- Write queries to match your indexes. Applying functions to an indexed column in your
WHEREclause will often prevent the index from being used. Consider a functional index instead. - Keep statistics up to date. The query planner relies on accurate statistics to make good decisions.
Preview of the Next Module
With this lesson, we conclude our deep dive into the database layer. We've journeyed from how data is physically stored on disk all the way up to advanced scaling and optimization strategies.
In our next module, we will pull the lens back and begin to trace the entire lifecycle of a request. Our first lesson will start at the very edge of your infrastructure: the web server. We'll explore how servers like Nginx receive an incoming HTTP request from the internet, process it, and forward it to your application server, starting our journey to connect the network to your code.