10.17. SQL Performance Basics
Table of Contents
Why SQL Performance Matters
When your application is small, almost any SQL query feels fast. Once you have thousands or millions of rows, badly written queries can make your whole backend feel slow.
In this chapter you will learn the basics of SQL performance, enough to:
- Recognize slow patterns.
- Write queries that scale better.
- Know when to reach for indexes or query changes.
More advanced optimization topics belong to later chapters, but the foundation starts here.
Goal: Your SQL should return the correct results in the simplest way that uses appropriate indexes and avoids unnecessary work.
How Databases Execute Queries (High Level)
Relational databases have a query planner or optimizer. When you send a SQL query, the database:
- Parses the SQL.
- Figures out possible ways to execute it.
- Chooses what it thinks is the cheapest plan.
- Executes that plan.
You can inspect this plan with commands like:
EXPLAIN SELECT * FROM users WHERE email = 'alice@example.com';You will see steps like:
- Sequential scan (full table scan).
- Index scan.
- Nested loop join.
- Sort, aggregate, etc.
You do not need to understand every detail yet. The key idea is:
The same SQL query can be executed in different ways, and some ways are much faster than others.
Your job is to write queries and design schemas so that the planner can choose a fast plan, usually by using indexes and avoiding unnecessary work.
Reading Execution Plans (Conceptually)
Every database has its own format, but common ideas repeat.
Example in PostgreSQL style:
EXPLAIN
SELECT * FROM users WHERE email = 'alice@example.com';Output might look like:
Index Scan using users_email_idx on users (cost=0.29..8.30 rows=1 width=128)
Index Cond: (email = 'alice@example.com')Key concepts that matter:
- Scan type
Seq Scan= sequential scan = full table scan.Index Scan/Index Only Scan= uses an index, usually faster for selective queries.- Rows: estimated number of rows processed at each step.
- Cost: a relative number used by the planner to compare plans.
Very simplified rule:
Prefer query plans that use index scans for selective filters and avoid unnecessary sequential scans on large tables.
If you see repeated full table scans on big tables for simple lookups, performance will not scale.
Full Table Scans vs Index Scans
Full Table Scan (Sequential Scan)
A full table scan checks every row. For example:
SELECT * FROM users WHERE last_login_at > NOW() - INTERVAL '1 day';
If there is no index on last_login_at, the database must read all rows to check the condition.
This might be fine for a table with 100 rows, but painful for 10 million rows.
Index Scan
An index acts like a sorted phone book for one or more columns. With an index, the database can jump directly to the matching rows.
Example:
CREATE INDEX idx_users_last_login_at ON users(last_login_at);Now the same query:
SELECT * FROM users WHERE last_login_at > NOW() - INTERVAL '1 day';
can use idx_users_last_login_at to find the relevant range instead of scanning everything.
Summary:
| Situation | Likely Scan Type | Performance on Large Table |
|---|---|---|
| No index on filter column | Sequential scan | Slow |
| Index on filter column, selective | Index / index-only | Fast |
| Filter matches almost all rows | Sometimes seq scan | Sometimes seq scan is cheaper |
The optimizer may still choose a full scan if almost all rows match, because jumping through an index may cost more than reading the table once.
Basic Indexing for Performance
You learned about indexes in a separate chapter. Here we focus on how they affect query speed.
Indexes help when:
- You filter with
WHERE column = valueor ranges like>,<,BETWEEN. - You join tables on a column.
- You use
ORDER BYorGROUP BYon columns.
Example schema:
CREATE TABLE users (
id SERIAL PRIMARY KEY,
email TEXT NOT NULL UNIQUE,
country TEXT,
created_at TIMESTAMP NOT NULL
);Built-in or typical indexes:
- Primary key on
id. - Unique index for
email.
If you frequently query by country and creation date:
SELECT * FROM users
WHERE country = 'US'
AND created_at >= NOW() - INTERVAL '7 days';you might add:
CREATE INDEX idx_users_country_created_at
ON users(country, created_at);Two important effects:
- Filters on
countryandcreated_atcan be much faster. - Sorting by
created_atfor a givencountrymight not require an extra sort step.
Create indexes for columns that you query often, especially in WHERE, JOIN, ORDER BY, or GROUP BY, but avoid creating too many indexes because every index slows down writes.
Too many or unnecessary indexes can hurt insert, update, and delete performance, and increase storage.
Writing Efficient WHERE Clauses
How you write conditions can decide whether an index can be used.
Sargable vs Non-sargable Conditions
A "sargable" condition allows the database to use an index on a column. A "non-sargable" condition forces the database to check each row.
Non-sargable example:
-- Bad for index usage
SELECT * FROM users
WHERE LOWER(email) = 'alice@example.com';
If you have an index on email, this query may not use it because the function LOWER(email) changes the column.
Better approach:
- Normalize values before storing (for example always store lowercase emails).
- Or create a functional index (database feature), which is advanced.
Simpler for beginners:
-- Store lowercase email at insert time, then query directly
SELECT * FROM users
WHERE email = 'alice@example.com';Another common non-sargable pattern:
-- Bad: function on the column
SELECT * FROM orders
WHERE DATE(created_at) = '2026-08-27';Better:
-- Good: function on the constant, or no function at all
SELECT * FROM orders
WHERE created_at >= '2026-08-27'::date
AND created_at < '2026-08-28'::date;
This allows a regular index on created_at to be used.
Using AND and OR
Indexes work best when the database can use them directly for each condition.
SELECT * FROM users
WHERE country = 'US'
AND active = true;
If you have an index on (country, active) or on country, the query can be fast.
OR conditions are often harder to optimize, for example:
SELECT * FROM users
WHERE country = 'US'
OR email = 'alice@example.com';
The optimizer may still find a good plan, but OR can sometimes lead to more work. In real projects, you sometimes rewrite complex OR queries as UNION of simpler queries, which advanced chapters will cover.
Limiting Results and Pagination
Always avoid asking for more rows than you need.
-- Bad pattern in APIs or UIs
SELECT * FROM users;If your table has a million users, the application will be overwhelmed.
Better:
SELECT * FROM users
ORDER BY id
LIMIT 50;
For APIs, combine ORDER BY with LIMIT and some pagination logic.
Basic offset pagination:
SELECT * FROM users
ORDER BY id
LIMIT 50 OFFSET 100;
But OFFSET can become slow for very large offsets, because the database still counts through the skipped rows.
Keyset pagination pattern is more efficient:
-- Get first page
SELECT * FROM users
ORDER BY id
LIMIT 50;
-- Then use the last seen id for the next page
SELECT * FROM users
WHERE id > 50
ORDER BY id
LIMIT 50;This avoids scanning many skipped rows.
Never fetch unbounded result sets in production APIs. Always use LIMIT and some form of pagination.
Selecting Only Needed Columns
Selecting fewer columns means:
- Less data to read from disk or memory.
- Less data to send over the network.
- Often less work for the database.
-- Bad if you only need names
SELECT * FROM users;Better:
SELECT id, email FROM users;This becomes more important when tables have large text or JSON columns.
In some databases, a query that only needs indexed columns can use an "index only scan," which can be even faster because it does not have to read the main table rows.
Avoiding N+1 Query Problems
The N+1 query problem happens when your application runs one query for a list, then another query for each item.
Example in pseudocode:
1) SELECT * FROM users LIMIT 100; -- 1 query
2) For each user, SELECT * FROM posts WHERE user_id = ?; -- 100 queries
Total = 101 queriesIf you have 10 000 users, this pattern can produce 10 001 queries.
Better: fetch related data in a single query with a JOIN.
SELECT u.id AS user_id,
u.email,
p.id AS post_id,
p.title
FROM users u
LEFT JOIN posts p ON p.user_id = u.id
WHERE u.id IN (1, 2, 3, ..., 100);Or:
SELECT * FROM posts
WHERE user_id IN (1, 2, 3, ..., 100);
Then group posts by user_id in your application code.
Summary:
| Pattern | Queries | Performance on N items |
|---|---|---|
| N+1 (per item) | N + 1 | Usually slow |
| Single join or IN query | 1 | Usually much faster |
If you see your backend making one small query in a loop, consider rewriting it to a single SQL query that fetches everything in one go.
Using JOINs Efficiently
JOINs are powerful but can also be slow if misused.
Index Join Columns
When you join tables, the join columns should usually be indexed.
Example:
SELECT o.*
FROM orders o
JOIN users u ON o.user_id = u.id
WHERE u.email = 'alice@example.com';Indexes that help:
- Primary key or index on
users.id(almost always exists). - Index on
orders.user_id.
Without index on orders.user_id, the database might:
- Find the user with the matching email using
users.emailindex. - Then scan the entire
orderstable to find rows with thatuser_id.
With an index on orders.user_id, it can jump directly to the user's orders.
Avoid Unnecessary JOINs
Do not join tables if you do not need their columns.
Bad:
SELECT o.id
FROM orders o
JOIN users u ON o.user_id = u.id;
If you do not use any users columns or filters, the join is useless and only adds work.
Better:
SELECT id FROM orders;Aggregations and GROUP BY
Queries with aggregates like COUNT, SUM, AVG, and GROUP BY can be expensive on large tables.
Example:
SELECT country, COUNT(*) AS users_count
FROM users
GROUP BY country;The database must:
- Scan rows.
- Group them by
country. - Count each group.
Effective tips:
- Index on the
GROUP BYcolumn can help, especially if the database can process data in index order. - Avoid grouping huge intermediate result sets when you can pre-filter first.
Less efficient:
SELECT country, COUNT(*)
FROM users
GROUP BY country
HAVING COUNT(*) > 1000;Better:
-- Often similar, but in some cases pre-filtering helps:
SELECT country, COUNT(*)
FROM users
WHERE created_at >= '2026-01-01'
GROUP BY country
HAVING COUNT(*) > 1000;You will learn more advanced aggregation optimizations later. For now, remember that aggregates on large tables can be heavy, so use filters and proper indexes.
Simple Query Rewriting Examples
Small changes sometimes make a big difference.
Example 1: LIKE vs Prefix Search
Query:
SELECT * FROM users
WHERE email LIKE '%example.com';
The leading % means the database cannot use a regular index on email. It must check every row.
If you only need to check the domain and you store emails as local_part@domain, you can split the domain into a separate column and index it:
CREATE TABLE users (
id SERIAL PRIMARY KEY,
local_part TEXT NOT NULL,
domain TEXT NOT NULL,
...
);
CREATE INDEX idx_users_domain ON users(domain);
SELECT * FROM users
WHERE domain = 'example.com';This is much faster on large datasets.
Example 2: In-application vs in-database filtering
Bad pattern:
1) SELECT * FROM orders;
2) Filter orders in application code by created_at, status, etc.Better:
SELECT * FROM orders
WHERE status = 'PAID'
AND created_at >= NOW() - INTERVAL '30 days';Let the database filter as much as possible. It is optimized for that work.
Measuring and Observing Query Performance
To improve performance, you must measure it.
Basic things you can do:
- Use
EXPLAINorEXPLAIN ANALYZEto see query plans. - Add a timeout or log slow queries in your database configuration.
- Log query durations from your application.
Example in PostgreSQL:
EXPLAIN ANALYZE
SELECT * FROM users WHERE email = 'alice@example.com';This shows both the plan and real execution time.
Do not guess about performance. Measure query times and inspect query plans to understand what is slow.
Practical Checklist for Beginners
When you write or review a query that might run on a big table, use this checklist:
- WHERE conditions
- Are they written so indexes can be used (no unnecessary functions on columns)?
- Do you avoid
SELECT *if not needed? - Indexes
- Are there indexes on the columns used in
WHEREandJOIN? - Are there too many indexes on a table you update frequently?
- Result size
- Does the query use
LIMITwhere appropriate? - Are you using pagination for API responses?
- JOINs
- Are join columns indexed?
- Are you joining only the tables you really need?
- N+1 issues
- Is your application sending one query per item in a list?
- Can you replace this with a single query using
JOINorIN? - Aggregations
- Are you aggregating only what you need and with appropriate filters?
- Does a
GROUP BYon a huge table actually need all rows?
If you follow this checklist, you will already avoid a large portion of common SQL performance problems that beginners run into.
You now have a working understanding of SQL performance basics: how indexes affect speed, what to look for in query plans, and how to avoid typical pitfalls such as full table scans, N+1 queries, and unbounded result sets. More advanced tuning will come later, but these foundations are what you will use every day as a backend developer.
Views: 8
KAHIBARO