DEV Community

Deepika Pusala
Deepika Pusala

Posted on

PostgreSQL Query Optimization: Indexes, EXPLAIN ANALYZE, B-tree vs GIN vs GiST & the N+1 Problem ๐Ÿš€

๐Ÿš€ 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;
Enter fullscreen mode Exit fullscreen mode

You want all posts by user 101. But how does the database find them?

  1. It can inspect every row one by one
  2. 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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

๐Ÿ“Œ 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);
Enter fullscreen mode Exit fullscreen mode

This creates an index on user_id. Now this query can potentially use it:

SELECT *
FROM posts
WHERE user_id = 101;
Enter fullscreen mode Exit fullscreen mode

Without an index:

Check row 1 โ†’ Check row 2 โ†’ Check row 3 โ†’ Check row 4 โ†’ ...
Enter fullscreen mode Exit fullscreen mode

With an index:

Search the index
   โ–ผ
Find user_id = 101
   โ–ผ
Locate matching row positions
   โ–ผ
Retrieve matching rows
Enter fullscreen mode Exit fullscreen mode

๐Ÿง  Important things to remember

  • An index is separate from the actual table data
  • An index helps speed up certain SELECT queries
  • An index consumes additional storage
  • INSERT, UPDATE and DELETE may 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?

๐Ÿ“Œ EXPLAIN shows the query execution plan: how PostgreSQL plans to retrieve the requested data.

EXPLAIN
SELECT *
FROM posts
WHERE user_id = 101;
Enter fullscreen mode Exit fullscreen mode

Possible output:

Seq Scan on posts
  Filter: (user_id = 101)
Enter fullscreen mode Exit fullscreen mode

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
...
Enter fullscreen mode Exit fullscreen mode

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
  1. Sequential Scan: Seq Scan on posts reads the table and checks rows
  2. Index Scan: Index Scan using idx_posts_user_id on posts uses an index to find matching rows, then retrieves the actual table rows
  3. 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
  4. 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)
Enter fullscreen mode Exit fullscreen mode
  • 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;
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode
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;
Enter fullscreen mode Exit fullscreen mode

EXPLAIN ANALYZE actually executes the query and shows the plan along with measured statistics:

EXPLAIN ANALYZE
SELECT *
FROM posts
WHERE user_id = 101;
Enter fullscreen mode Exit fullscreen mode
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
Enter fullscreen mode Exit fullscreen mode
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 ANALYZE executes the query. For SELECT that's generally safe. For INSERT, UPDATE or DELETE it really performs the modification. Wrap it in a transaction and ROLLBACK if you want to test without keeping changes.

BEGIN;

EXPLAIN ANALYZE
UPDATE posts SET is_deleted = true WHERE id = 10;

ROLLBACK;
Enter fullscreen mode Exit fullscreen mode

You can also ask for buffer statistics:

EXPLAIN (ANALYZE, BUFFERS)
SELECT *
FROM posts
WHERE user_id = 101;
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

(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;
Enter fullscreen mode Exit fullscreen mode

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"]
Enter fullscreen mode Exit fullscreen mode

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']
);
Enter fullscreen mode Exit fullscreen mode

Query:

SELECT *
FROM posts
WHERE tags @> ARRAY['backend'];
Enter fullscreen mode Exit fullscreen mode

@> means contains: "give me posts whose tags contain backend". Now create a GIN index:

CREATE INDEX idx_posts_tags
ON posts USING GIN(tags);
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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"}';
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

A spatial query:

SELECT *
FROM locations
WHERE ST_DWithin(
    geom,
    ST_SetSRID(ST_MakePoint(77.59, 12.97), 4326)::geography,
    5000
);
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

But what about this?

-- Second column only
SELECT * FROM posts
WHERE created_at > '2026-01-01';
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode
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;
Enter fullscreen mode Exit fullscreen mode

A possible index:

CREATE INDEX idx_posts_user_deleted_created
ON posts(user_id, is_deleted, created_at DESC);
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

Let's count the queries.

Step 1: fetch all posts (1 query)

SELECT * FROM posts LIMIT 10;
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

(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;
Enter fullscreen mode Exit fullscreen mode

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()
Enter fullscreen mode Exit fullscreen mode

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()
Enter fullscreen mode Exit fullscreen mode

First query:

SELECT * FROM posts LIMIT 10;
Enter fullscreen mode Exit fullscreen mode

Second query:

SELECT * FROM comments
WHERE post_id IN (1, 2, 3, 4, 5, 6, 7, 8, 9, 10);
Enter fullscreen mode Exit fullscreen mode

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
  }
]
Enter fullscreen mode Exit fullscreen mode

A poor implementation queries all four things separately for every post. A better one:

  1. Fetches posts using pagination
  2. Eager-loads authors
  3. Batch-fetches comments if needed
  4. Calculates like counts using aggregation or a grouped query
  5. 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;
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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');
Enter fullscreen mode Exit fullscreen mode
Without indexes:  Insert row โ†’ Done

With indexes:     Insert row
                    โ–ผ
                  Update user_id index
                    โ–ผ
                  Update created_at index
                    โ–ผ
                  Update other indexes
                    โ–ผ
                  Done
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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:

  1. Frequently executed queries
  2. WHERE conditions
  3. JOIN conditions
  4. ORDER BY conditions
  5. Actual query execution plans
  6. 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;
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

You don't need a million posts at once. Instead:

SELECT *
FROM posts
ORDER BY created_at DESC
LIMIT 20;
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

A matching index:

CREATE INDEX idx_posts_created_id
ON posts(created_at DESC, id DESC);
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

โœ… Better: let PostgreSQL aggregate.

SELECT COUNT(*)
FROM likes
WHERE post_id = 10;
Enter fullscreen mode Exit fullscreen mode

9.5 ๐Ÿ”ง Avoid functions on indexed columns when possible

SELECT *
FROM users
WHERE LOWER(email) = 'abc@gmail.com';
Enter fullscreen mode Exit fullscreen mode

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));
Enter fullscreen mode Exit fullscreen mode

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
);
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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:

  1. ๐Ÿข Identify the slow API. GET /feed takes too long
  2. ๐Ÿ”Ž Inspect the SQL queries. Check which queries SQLAlchemy generates
  3. ๐Ÿ” Find unnecessary database calls. Is there an N+1 problem?
  4. ๐Ÿ“Š Analyze execution plans. Use EXPLAIN ANALYZE and inspect scans, rows, sorting and buffers
  5. ๐Ÿ—‚๏ธ Choose suitable indexes. B-tree for ordinary lookups, GIN for supported JSONB/array searches, GiST for spatial or specialized operations
  6. ๐Ÿ“ฆ Optimize data retrieval. Use JOINs, eager loading, aggregation and pagination appropriately
  7. โœ… 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 EXPLAIN on a query without an index
  • [ ] Create a B-tree index on user_id
  • [ ] Run EXPLAIN ANALYZE again and compare plans
  • [ ] Create a composite index on user_id and created_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() or selectinload()
  • [ ] 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)