๐ PostgreSQL Query Optimization: Let's Learn from ZERO!
Imagine your social media app has only 30 posts today. Everything is fast. Now imagine it grows to:
- ๐ฅ 10,000 users
- ๐ 1 million posts
- โค๏ธ 10 million likes
- ๐ฌ Millions of comments
Suddenly some API requests become slow, the database takes longer to retrieve data, and your server does more work than necessary.
๐ Query optimization is about making database operations faster and more efficient without changing the correctness of the results.
We'll learn everything from zero, using PostgreSQL and examples from a social-feed project. ๐ฏ
1. ๐ค What Does Query Optimization Mean?
Take a posts table:
| id | user_id | caption |
|---|---|---|
| 1 | 101 | My first post |
| 2 | 102 | Beautiful day |
| 3 | 101 | Weekend vibes |
| 4 | 103 | Coding time |
| 5 | 101 | Hello world |
You run:
SELECT *
FROM posts
WHERE user_id = 101;
You want all posts by user 101. But how does the database find them?
- It can inspect every row one by one
- It can use an index to find matching rows more directly
The second approach can be much faster for suitable queries. That's where indexing and query optimization come in.
1.1 ๐จ What is a database query?
A query is simply a request sent to the database:
SELECT * FROM users; -- get all users
SELECT * FROM posts WHERE id = 10; -- get post 10
SELECT * FROM likes WHERE post_id = 25; -- get all likes of post 25
Every time your FastAPI backend needs data, it may execute queries:
User opens the app
โผ
React frontend requests /feed
โผ
FastAPI receives the request
โผ
Service layer executes a database query
โผ
PostgreSQL retrieves matching posts
โผ
FastAPI returns a JSON response
โผ
React displays posts
If the database query is slow, the API response may also become slow.
โ ๏ธ Query optimization isn't only about writing shorter SQL. It's about reducing unnecessary database work.
2. ๐ What is a Database Index?
This is the foundation of the whole topic. Imagine a 1,000-page textbook and you want the topic "Normalization".
- Without an index: you start at page 1, then page 2, page 3... until you find it on page 650. That's like a Sequential Scan
-
With an index: the back of the book says
Normalization โ Page 650. You go straight there. That's like an indexed lookup
Normalization โ Page 650
Transactions โ Page 720
Primary Key โ Page 100
SQL Joins โ Page 250
๐ A database index is an additional data structure that helps PostgreSQL locate rows more efficiently.
CREATE INDEX idx_posts_user_id
ON posts(user_id);
This creates an index on user_id. Now this query can potentially use it:
SELECT *
FROM posts
WHERE user_id = 101;
Without an index:
Check row 1 โ Check row 2 โ Check row 3 โ Check row 4 โ ...
With an index:
Search the index
โผ
Find user_id = 101
โผ
Locate matching row positions
โผ
Retrieve matching rows
๐ง Important things to remember
- An index is separate from the actual table data
- An index helps speed up certain
SELECTqueries - An index consumes additional storage
-
INSERT,UPDATEandDELETEmay become slower because indexes also need maintenance - Not every query benefits from an index
๐ Golden rule: index columns based on actual query patterns, not randomly.
3. ๐ EXPLAIN: Understanding How PostgreSQL Executes Queries
You write a query, but how do you know PostgreSQL runs it efficiently? You can't assume a query is fast just because it looks simple. PostgreSQL provides EXPLAIN.
3.1 ๐ค What is EXPLAIN?
๐
EXPLAINshows the query execution plan: how PostgreSQL plans to retrieve the requested data.
EXPLAIN
SELECT *
FROM posts
WHERE user_id = 101;
Possible output:
Seq Scan on posts
Filter: (user_id = 101)
A. Seq Scan. Seq means sequential. PostgreSQL reads through the table's rows to find matching records.
posts table
Row 1 โ Check
Row 2 โ Check
Row 3 โ Check
...
Is Seq Scan bad? NO! This is extremely important. If your table has only 10 rows, using an index might actually cost more than checking all 10. PostgreSQL intelligently chooses between execution strategies.
B. Filter. Filter: (user_id = 101) means PostgreSQL keeps rows satisfying user_id = 101 and discards the others.
3.2 ๐ Common scan types
| Scan type | Meaning |
|---|---|
| Seq Scan | Reads table rows sequentially |
| Index Scan | Uses an index and retrieves table rows |
| Index Only Scan | Can retrieve required data directly from the index |
| Bitmap Index Scan | Uses an index to identify matching row locations |
| Bitmap Heap Scan | Retrieves matching table rows using a bitmap |
-
Sequential Scan:
Seq Scan on postsreads the table and checks rows -
Index Scan:
Index Scan using idx_posts_user_id on postsuses an index to find matching rows, then retrieves the actual table rows - Index Only Scan: if the index contains all the columns the query needs, PostgreSQL may answer without visiting the table's data pages. It can still need visibility checks depending on the visibility map
- Bitmap Index Scan: useful when many matching rows must be retrieved. PostgreSQL first identifies matching row locations using the index, then fetches table pages efficiently
3.3 ๐บ๏ธ What is a query execution plan?
๐ A query execution plan is the sequence of operations PostgreSQL chooses to execute your query.
Index Scan using idx_posts_user_id
on posts
Index Cond: (user_id = 101)
-
Index Cond: the condition used to search through the index -
Filter: an additional condition applied to rows after they are retrieved
For example:
SELECT *
FROM posts
WHERE user_id = 101
AND is_deleted = false;
PostgreSQL might use an index on user_id, then filter out deleted posts.
3.4 ๐ฐ What does "cost" mean?
Index Scan (cost=0.15..8.20 rows=5 width=120)
| Part | Meaning |
|---|---|
0.15 |
Estimated startup cost |
8.20 |
Estimated total cost |
rows=5 |
Estimated number of returned rows |
width=120 |
Estimated average row width in bytes |
โ ๏ธ PostgreSQL's cost is an internal estimate, not milliseconds. It helps PostgreSQL compare different possible plans.
4. โฑ๏ธ EXPLAIN ANALYZE: Measuring Actual Execution
A very common interview question: what's the difference between EXPLAIN and EXPLAIN ANALYZE?
EXPLAIN shows the estimated plan and doesn't normally execute the query:
EXPLAIN
SELECT *
FROM posts
WHERE user_id = 101;
EXPLAIN ANALYZE actually executes the query and shows the plan along with measured statistics:
EXPLAIN ANALYZE
SELECT *
FROM posts
WHERE user_id = 101;
Index Scan using idx_posts_user_id
on posts
(cost=0.15..8.20 rows=5 width=120)
(actual time=0.025..0.041 rows=5 loops=1)
Planning Time: 0.120 ms
Execution Time: 0.065 ms
| Term | Meaning |
|---|---|
actual time |
Measured execution time |
rows |
Actual number of rows produced |
loops |
Number of times the operation executed |
Planning Time |
Time spent planning the query |
Execution Time |
Time spent executing the query |
๐ฏ Interview important:
EXPLAIN= what PostgreSQL estimates it will do.EXPLAIN ANALYZE= what PostgreSQL actually did, with measured statistics.๐จ Warning:
EXPLAIN ANALYZEexecutes the query. ForSELECTthat's generally safe. ForINSERT,UPDATEorDELETEit really performs the modification. Wrap it in a transaction andROLLBACKif you want to test without keeping changes.
BEGIN;
EXPLAIN ANALYZE
UPDATE posts SET is_deleted = true WHERE id = 10;
ROLLBACK;
You can also ask for buffer statistics:
EXPLAIN (ANALYZE, BUFFERS)
SELECT *
FROM posts
WHERE user_id = 101;
BUFFERS shows information about database pages accessed from shared buffers and storage, which helps you spot queries doing too much disk or memory I/O.
5. ๐งฐ Types of Indexes: B-tree vs GIN vs GiST
PostgreSQL supports different index types because different queries search for different kinds of data: a user ID, a word inside a text document, locations within a distance, elements inside a JSON document. One index type isn't suitable for every situation.
5.1 ๐ณ B-tree Index
The most commonly used. B-tree stands for Balanced Tree and it's PostgreSQL's default index type.
CREATE INDEX idx_users_email
ON users(email);
This creates a B-tree index by default.
Imagine these numbers: 10, 20, 30, 40, 50, 60, 70. A simplified tree:
40
/ \
20 60
(smaller) (larger)
(A simplified binary-tree illustration. Real B-trees have multiple keys and children per node.) The database navigates the tree instead of checking every value.
B-tree is excellent for:
-- 1. Equality searches
SELECT * FROM users WHERE id = 10;
-- 2. Range searches
SELECT * FROM posts WHERE id > 100;
-- 3. Sorting
SELECT * FROM users ORDER BY created_at;
-- 4. Minimum and maximum values
SELECT MIN(created_at) FROM posts;
It's particularly useful for integers, IDs, email addresses, dates and timestamps, numeric values, and sorting and range conditions. Whenever you search, compare or sort ordinary scalar values, B-tree is usually the first index type to consider.
5.2 ๐ GIN Index
GIN stands for Generalized Inverted Index. It's especially useful for searching inside collections of values or documents.
A post has tags:
Caption: Learning PostgreSQL Tags: ["database", "backend", "python"]
Caption: Learning React Tags: ["frontend", "javascript", "web"]
You want all posts containing the tag backend. Use PostgreSQL's array type:
CREATE TABLE posts (
id SERIAL PRIMARY KEY,
caption TEXT,
tags TEXT[]
);
INSERT INTO posts (caption, tags)
VALUES
(
'Learning PostgreSQL',
ARRAY['database', 'backend', 'python']
),
(
'Learning React',
ARRAY['frontend', 'javascript', 'web']
);
Query:
SELECT *
FROM posts
WHERE tags @> ARRAY['backend'];
@> means contains: "give me posts whose tags contain backend". Now create a GIN index:
CREATE INDEX idx_posts_tags
ON posts USING GIN(tags);
PostgreSQL can use it for supported array containment queries.
Why "inverted"? Think of a dictionary. Instead of storing Post 1 โ database, backend, python, an inverted index conceptually maps each value back to the records containing it:
database โ Post 1
backend โ Post 1
python โ Post 1
frontend โ Post 2
javascript โ Post 2
web โ Post 2
Where is GIN useful? PostgreSQL arrays, JSONB data, full-text search, and searching for elements within collections. A JSONB example:
CREATE TABLE products (
id SERIAL PRIMARY KEY,
details JSONB
);
INSERT INTO products(details)
VALUES ('{"color": "black", "brand": "Nike"}');
CREATE INDEX idx_products_details
ON products USING GIN(details);
SELECT *
FROM products
WHERE details @> '{"color": "black"}';
This searches for JSON documents containing the specified JSON structure.
๐ง GIN is not a replacement for B-tree. They solve different search problems.
5.3 ๐บ๏ธ GiST Index
GiST stands for Generalized Search Tree. It's a flexible indexing framework for different kinds of data and search operations. One great example is geographical data.
Imagine finding "all users within 5 km of Bengaluru Palace". That's a spatial search, and a B-tree isn't designed to efficiently handle every kind of spatial relationship. GiST can support spatial indexing through extensions such as PostGIS.
CREATE INDEX idx_locations_geom
ON locations USING GIST(geom);
A spatial query:
SELECT *
FROM locations
WHERE ST_DWithin(
geom,
ST_SetSRID(ST_MakePoint(77.59, 12.97), 4326)::geography,
5000
);
This finds locations within 5,000 metres of the point, assuming geom is stored with a geography-compatible coordinate reference system.
Where is GiST useful? Geographic and spatial searches, geometry operations, range types, and certain similarity searches using suitable operator classes.
โ๏ธ B-tree vs GIN vs GiST
| Feature | B-tree | GIN | GiST |
|---|---|---|---|
| Main purpose | Ordinary values | Elements inside collections/documents | Flexible search structures |
| Equality | Yes | Supported operators | Operator-class dependent |
| Range | Excellent | Not generally | Supported for suitable types |
| Sorting | Yes | Generally no | Generally no |
| Arrays | Limited | Excellent | Operator-dependent |
| JSONB | Some operations | Excellent for supported operators | Operator-dependent |
| Spatial search | No general spatial support | No general spatial support | Excellent with PostGIS |
๐ฏ Interview takeaway:
- B-tree โ IDs, emails, dates, range searches
- GIN โ arrays, JSONB, full-text search
- GiST โ spatial and specialized searches
๐ PostgreSQL also has BRIN and hash indexes. You may meet them in advanced work, but B-tree, GIN and GiST are the important three here.
6. ๐งฑ Composite Indexes
Another VERY important concept. Take this table:
| id | user_id | created_at | caption |
|---|---|---|---|
| 1 | 101 | Nov 1 | Hello |
| 2 | 102 | Nov 2 | Beautiful |
| 3 | 101 | Nov 3 | Coding |
| 4 | 103 | Nov 4 | Weekend |
| 5 | 101 | Nov 5 | Learning |
You frequently run:
SELECT *
FROM posts
WHERE user_id = 101
ORDER BY created_at DESC;
That's two operations: find posts of user 101, then sort them newest first. You could create two separate indexes:
CREATE INDEX idx_posts_user
ON posts(user_id);
CREATE INDEX idx_posts_created
ON posts(created_at);
But PostgreSQL might need to combine index information or do extra sorting. There's a better approach.
6.1 ๐ค What is a composite index?
๐ A composite index is an index created using multiple columns.
CREATE INDEX idx_posts_user_created
ON posts(user_id, created_at DESC);
One index, two columns. Conceptually it's organized by user_id first, then created_at:
user_id = 101
โโโ Nov 5
โโโ Nov 3
โโโ Nov 1
user_id = 102
โโโ Nov 2
user_id = 103
โโโ Nov 4
The actual index is ordered by the first column, then the second column within matching first-column values. PostgreSQL can potentially find a user's posts and retrieve them in date order without a separate sorting step.
6.2 โ ๏ธ Column order matters!
CREATE INDEX idx_posts_user_created
ON posts(user_id, created_at);
This index is particularly useful for:
-- Both columns
SELECT * FROM posts
WHERE user_id = 101 AND created_at > '2026-01-01';
-- First column only
SELECT * FROM posts
WHERE user_id = 101;
But what about this?
-- Second column only
SELECT * FROM posts
WHERE created_at > '2026-01-01';
A regular B-tree composite index is generally much less effective here, because it's ordered primarily by user_id.
๐ PostgreSQL 18 added some B-tree skip-scan capabilities, but don't assume a composite index is an equally good substitute for a separate index on its second column.
๐ The Leftmost Prefix Rule
CREATE INDEX idx_example
ON posts(user_id, created_at, is_deleted);
| Query condition | Suitability |
|---|---|
user_id |
โ Good |
user_id + created_at
|
โ Good |
user_id + created_at + is_deleted
|
โ Good |
created_at alone |
โ ๏ธ Generally less suitable |
is_deleted alone |
โ ๏ธ Generally less suitable |
This is a general guideline, not an absolute rule.
๐ When to create composite indexes
When your app repeatedly runs queries using the same combination of columns. A social feed might have:
SELECT *
FROM posts
WHERE user_id = 101
AND is_deleted = false
ORDER BY created_at DESC
LIMIT 20;
A possible index:
CREATE INDEX idx_posts_user_deleted_created
ON posts(user_id, is_deleted, created_at DESC);
But always check your actual query patterns and data distribution before deciding the column order.
โ ๏ธ More indexes do not automatically mean better performance.
7. ๐ The N+1 Query Problem
๐ฏ One of the most common database performance problems in apps using ORMs such as SQLAlchemy, Django ORM and Hibernate.
Your home feed contains 10 posts. Each has post info, author info, comments and likes. A beginner might write:
posts = session.query(Post).limit(10).all()
for post in posts:
author = session.get(User, post.user_id)
print(post.caption, author.username)
Let's count the queries.
Step 1: fetch all posts (1 query)
SELECT * FROM posts LIMIT 10;
Step 2: fetch each author (10 more queries)
SELECT * FROM users WHERE id = 101;
SELECT * FROM users WHERE id = 102;
SELECT * FROM users WHERE id = 103;
-- ...and so on
Total: 1 + 10 = 11 queries. This is the N+1 Query Problem:
- 1 query fetches the main records
- N additional queries fetch related data for each record
Fetch 100 posts and you might run 101 queries. Fetch 1,000 and you might run 1,001. That seriously affects performance.
7.1 ๐ค Why is N+1 a problem?
If every query takes 10 ms:
10 posts: 11 queries ร 10 ms = 110 ms
100 posts: 101 queries ร 10 ms = 1010 ms
(An illustration assuming sequential queries with equal latency. Real timings vary.) Every unnecessary database round trip adds overhead. Even if each query is individually fast, hundreds of calls make your API slow.
7.2 โ How do we solve N+1?
Solution 1: use a JOIN
SELECT
posts.id,
posts.caption,
users.username
FROM posts
JOIN users
ON posts.user_id = users.id
LIMIT 10;
PostgreSQL retrieves post and author information in one query.
Solution 2: eager loading in SQLAlchemy. SQLAlchemy supports two common strategies: joinedload() and selectinload().
A. joinedload()
from sqlalchemy import select
from sqlalchemy.orm import joinedload
stmt = (
select(Post)
.options(joinedload(Post.author))
.limit(10)
)
posts = session.scalars(stmt).all()
SQLAlchemy loads posts and their authors using a JOIN, so related data arrives as part of the query. For collection relationships, joined loading can multiply result rows, and SQLAlchemy requires .unique() when consuming results in that case.
B. selectinload()
from sqlalchemy import select
from sqlalchemy.orm import selectinload
stmt = (
select(Post)
.options(selectinload(Post.comments))
.limit(10)
)
posts = session.scalars(stmt).all()
First query:
SELECT * FROM posts LIMIT 10;
Second query:
SELECT * FROM comments
WHERE post_id IN (1, 2, 3, 4, 5, 6, 7, 8, 9, 10);
Instead of 10 separate comment queries, SQLAlchemy fetches related comments in one batch. A major improvement. ๐
โ๏ธ joinedload vs selectinload
| Feature | joinedload |
selectinload |
|---|---|---|
| Strategy | SQL JOIN | Separate batch query |
| Number of queries | Usually fewer | Usually more than one |
| Many-to-one | Often excellent | Also suitable |
| One-to-many | Can duplicate parent rows | Often preferable |
| Large collections | Can produce huge joined results | Often more efficient |
Practical guideline:
- Use
joinedload()for many-to-one or one-to-one relationships when appropriate - Use
selectinload()for one-to-many and many-to-many relationships in many common cases
๐ก Neither is universally best. Measure the actual workload.
7.3 ๐ฑ N+1 in a social-feed app
For GET /feed, the response contains posts, authors, comments and like counts:
[
{
"id": 1,
"caption": "Beautiful day",
"author": { "username": "deepika" },
"comments": [],
"likes_count": 25
}
]
A poor implementation queries all four things separately for every post. A better one:
- Fetches posts using pagination
- Eager-loads authors
- Batch-fetches comments if needed
- Calculates like counts using aggregation or a grouped query
- Builds the response without additional queries inside a loop
SELECT
post_id,
COUNT(*) AS likes_count
FROM likes
WHERE post_id IN (1, 2, 3, 4, 5)
GROUP BY post_id;
That calculates counts for multiple posts in one query. This is the kind of thinking expected from a good backend developer! ๐ง
8. โ๏ธ Performance vs Storage Trade-offs
If indexes are so useful, why not index every column? Because indexes are not free.
8.1 ๐พ Indexes consume storage
posts table โ 500 MB
user_id index โ 100 MB
created_at index โ 120 MB
tags GIN index โ 200 MB
-----------------------------
Total โ 920 MB
Your table was 500 MB, but with three indexes storage grew to 920 MB in this simplified example. Real sizes depend on data types, values, table structure and index configuration.
8.2 ๐ข Indexes can slow down INSERT
INSERT INTO posts(user_id, caption)
VALUES (101, 'My new post');
Without indexes: Insert row โ Done
With indexes: Insert row
โผ
Update user_id index
โผ
Update created_at index
โผ
Update other indexes
โผ
Done
Every relevant index needs maintenance, so more indexes can mean more write overhead.
8.3 ๐ข Indexes can slow down UPDATE and DELETE
UPDATE posts
SET user_id = 102
WHERE id = 10;
If user_id is indexed, PostgreSQL must maintain that index as the indexed value changes. DELETE operations must also maintain index entries.
8.4 ๐ Indexes improve read performance
If your app frequently runs SELECT * FROM posts WHERE user_id = 101;, an appropriate index can significantly reduce the work needed to find those posts. So there's a trade-off:
| โ Advantages | โ Disadvantages |
|---|---|
| Faster suitable reads | Additional storage |
| Faster filtering | Slower writes |
| Efficient sorting | Index maintenance |
| Faster joins in suitable cases | More database complexity |
The right approach: don't create an index simply because a column exists. Create indexes based on:
- Frequently executed queries
-
WHEREconditions -
JOINconditions -
ORDER BYconditions - Actual query execution plans
- The balance between reads and writes
9. ๐ ๏ธ Other Important Query Optimization Techniques
Indexes are only one part of optimization.
9.1 ๐ซ Avoid SELECT *
SELECT *
FROM users
WHERE id = 10;
This retrieves every column. If the users table has id, username, email, password_hash, bio, profile_picture, created_at, address, preferences but the frontend only needs username and profile_picture:
SELECT username, profile_picture
FROM users
WHERE id = 10;
Why?
- Less data transferred
- Less memory used
- Less unnecessary processing
- May allow an index-only scan if a suitable covering index exists and visibility conditions permit
๐
SELECT *is not automatically slow. It's inefficient when you retrieve data you don't need.
9.2 ๐ Use LIMIT and pagination
With 1 million posts, this is problematic for a feed:
SELECT * FROM posts;
You don't need a million posts at once. Instead:
SELECT *
FROM posts
ORDER BY created_at DESC
LIMIT 20;
For large datasets, cursor pagination is often preferable to deep OFFSET pagination:
SELECT *
FROM posts
WHERE created_at < '2026-11-01 10:00:00'
ORDER BY created_at DESC
LIMIT 20;
In production, use a stable unique tie-breaker such as id:
SELECT *
FROM posts
WHERE (created_at, id) < (:cursor_created_at, :cursor_id)
ORDER BY created_at DESC, id DESC
LIMIT 20;
A matching index:
CREATE INDEX idx_posts_created_id
ON posts(created_at DESC, id DESC);
9.3 ๐ Avoid unnecessary JOINs
SELECT *
FROM posts
JOIN users ON posts.user_id = users.id
JOIN comments ON posts.id = comments.post_id
JOIN likes ON posts.id = likes.post_id;
This can produce a huge number of rows. If one post has 5 comments and 10 likes, joining both collections produces 50 combinations for that single post. This is called row multiplication. Only join the information you actually need, and use separate aggregate queries or appropriate loading strategies when that's better.
9.4 ๐งฎ Use aggregation instead of loading everything
Need only the number of likes?
โ Inefficient: fetch all rows and count them in Python.
SELECT *
FROM likes
WHERE post_id = 10;
โ
Better: let PostgreSQL aggregate.
SELECT COUNT(*)
FROM likes
WHERE post_id = 10;
9.5 ๐ง Avoid functions on indexed columns when possible
SELECT *
FROM users
WHERE LOWER(email) = 'abc@gmail.com';
A normal B-tree index on email might not be usable for this expression. Create an expression index:
CREATE INDEX idx_users_lower_email
ON users(LOWER(email));
Now the query can potentially use it.
10. ๐งช A Practical Optimization Exercise
CREATE TABLE posts (
id SERIAL PRIMARY KEY,
user_id INTEGER,
caption TEXT,
created_at TIMESTAMP,
is_deleted BOOLEAN DEFAULT FALSE
);
Your app frequently runs:
SELECT id, user_id, caption, created_at
FROM posts
WHERE user_id = 101
AND is_deleted = false
ORDER BY created_at DESC
LIMIT 20;
Step 1: check the original execution plan
EXPLAIN ANALYZE
SELECT id, user_id, caption, created_at
FROM posts
WHERE user_id = 101
AND is_deleted = false
ORDER BY created_at DESC
LIMIT 20;
Observe: Is PostgreSQL using Seq Scan? How many rows are examined? Is there a Sort operation? How long does execution take?
Step 2: create a composite index
CREATE INDEX idx_posts_user_deleted_created
ON posts(user_id, is_deleted, created_at DESC);
Step 3: run EXPLAIN ANALYZE again and compare. You may see an Index Scan instead of a Sequential Scan, and possibly no separate Sort. But if the table is small, PostgreSQL may still choose Seq Scan. That doesn't mean your index is broken.
Step 4: understand the result
| Before | After |
|---|---|
| Possible sequential scan | Possible index scan |
| May inspect many rows | Can navigate to matching entries |
| May require sorting | Index may provide ordering |
| Less index storage | Additional index storage |
That's how you investigate and optimize real database queries. ๐ฌ
11. โ ๏ธ Common Mistakes
| Mistake | Better approach |
|---|---|
| Creating indexes on every column | Index based on query patterns |
| Assuming Seq Scan is always bad | Check table size and execution cost |
Using SELECT * everywhere |
Fetch only required columns |
| Fetching all records | Use pagination |
| Ignoring N+1 queries | Use eager loading and batch queries |
| Creating too many indexes | Balance read and write performance |
| Ignoring composite index order | Design indexes around actual conditions |
| Optimizing without measuring | Use EXPLAIN ANALYZE
|
| Assuming an index always improves speed | Compare actual execution plans |
12. ๐งต How It All Connects in a Real Backend
Your mentor asks you to optimize GET /feed, which retrieves posts, authors and likes. Here's the workflow:
- ๐ข Identify the slow API.
GET /feedtakes too long - ๐ Inspect the SQL queries. Check which queries SQLAlchemy generates
- ๐ Find unnecessary database calls. Is there an N+1 problem?
- ๐ Analyze execution plans. Use
EXPLAIN ANALYZEand inspect scans, rows, sorting and buffers - ๐๏ธ Choose suitable indexes. B-tree for ordinary lookups, GIN for supported JSONB/array searches, GiST for spatial or specialized operations
- ๐ฆ Optimize data retrieval. Use JOINs, eager loading, aggregation and pagination appropriately
- โ Measure again. Compare performance and make sure results are still correct
๐ก Database query time and API response time are not the same. Your API might be slow because of database execution, network latency, Python processing, JSON serialization or external API calls. Don't blame PostgreSQL for every slow endpoint!
13. ๐ค Interview Questions and Answers
Q1. What is query optimization?
Improving database query performance by reducing unnecessary operations, improving execution plans and retrieving data efficiently.
Q2. What is an index?
An additional data structure that helps a database locate records faster, without scanning the entire table in suitable cases.
Q3. What is EXPLAIN?
It displays the execution plan PostgreSQL estimates it will use to execute a query.
Q4. What is EXPLAIN ANALYZE?
It executes the query and displays the actual execution statistics along with the plan.
Q5. What's the difference between B-tree and GIN?
B-tree suits ordinary values, equality, ranges and sorting. GIN is useful for searching elements within arrays, JSONB documents and full-text search.
Q6. What is GiST?
A flexible indexing framework for specialized search operations, including spatial queries with PostGIS.
Q7. What is a composite index?
An index created using multiple columns, for example CREATE INDEX idx_user_date ON posts(user_id, created_at);
Q8. Why does column order matter in a composite index?
A B-tree composite index is ordered primarily by its first column, then subsequent columns, so the order affects which queries can efficiently use it.
Q9. What is the N+1 query problem?
It happens when an application runs one query to retrieve parent records and then an additional query for each parent to retrieve related data.
Q10. How do you solve N+1 in SQLAlchemy?
Use eager loading strategies such as joinedload() and selectinload(), or suitable batch queries.
Q11. Why shouldn't we create indexes on every column?
Indexes consume storage and increase INSERT, UPDATE and DELETE overhead because the indexes must be maintained.
Q12. What is an Index Only Scan?
A scan that can obtain the required data from the index without fetching the corresponding table rows, subject to visibility checks.
14. ๐ Final Revision Sheet
| Concept | Remember this |
|---|---|
| Query Optimization | Make database queries efficient |
| Index | Helps locate records faster |
EXPLAIN |
Estimated execution plan |
EXPLAIN ANALYZE |
Actual execution statistics |
| Seq Scan | Reads table rows sequentially |
| Index Scan | Uses an index to locate records |
| Index Only Scan | Retrieves required data from the index when possible |
| B-tree | IDs, equality, ranges, sorting |
| GIN | Arrays, JSONB, full-text search |
| GiST | Spatial and specialized searches |
| Composite Index | Multiple columns in one index |
| Leftmost Prefix | Leading columns matter |
| N+1 | One main query plus N additional queries |
joinedload |
Eager loading using JOIN |
selectinload |
Eager loading using batch SELECT |
| Storage trade-off | Indexes consume extra disk space |
| Write trade-off | Index maintenance adds write overhead |
15. ๐๏ธ Practice Checklist
- [ ] Create a PostgreSQL table containing 100,000 sample posts
- [ ] Run
EXPLAINon a query without an index - [ ] Create a B-tree index on
user_id - [ ] Run
EXPLAIN ANALYZEagain and compare plans - [ ] Create a composite index on
user_idandcreated_at - [ ] Create a GIN index on an array column and test
@> - [ ] Understand a GiST index using a PostGIS example
- [ ] Reproduce an N+1 problem using SQLAlchemy
- [ ] Fix it using
joinedload()orselectinload() - [ ] Compare query execution time, index size and write overhead
๐ก Don't just memorize B-tree, GIN, GiST and
EXPLAIN. Actually run queries in PostgreSQL and watch how the execution plan changes. That's how you develop real database optimization skills.๐ฏ A good backend developer doesn't just make queries work. They make queries work **efficiently* as the application grows.*
If this post helped you, drop a โค๏ธ or ๐ฆ and share it with a friend learning backend development. Happy coding! ๐ฉโ๐ปโจ
Top comments (0)