11.12 PostgreSQL Performance Basics
Table of Contents
Why PostgreSQL Performance Matters
As your application grows, the database often becomes the bottleneck. PostgreSQL is powerful and feature rich, but it will only be fast if you:
- Design queries carefully
- Use indexes correctly
- Configure the database sensibly
- Monitor what is actually slow
In this chapter you will learn practical, beginner friendly techniques to understand and improve PostgreSQL performance, without going into low level internals.
You should already know basic SQL and PostgreSQL usage. We will focus on what is specific to PostgreSQL performance basics.
How PostgreSQL Executes a Query
Before you can optimize, you need a basic idea of what PostgreSQL does when you run a query.
The Main Steps
When you send a query like:
SELECT * FROM users WHERE email = 'alice@example.com';PostgreSQL roughly does:
- Parse
Checks that the SQL is valid. - Rewrite
Applies some internal rules, for example for views. - Plan
Decides how to execute the query.
For example, should it: - Scan the whole table
- Use an index
- Use a nested loop join or a hash join
- Execute
Runs the chosen plan and returns rows.
The planning step is critical for performance.
Sequential Scan vs Index Scan
The most common question PostgreSQL asks for a simple SELECT is:
Should I scan the whole table or use an index?
- Sequential scan
Reads every row in the table and checks the condition.
Good when: - The table is small, or
- The condition matches many rows.
- Index scan
Uses an index to jump directly to matching rows.
Good when: - The condition matches a small part of the table.
Table example:
CREATE TABLE users (
id bigserial PRIMARY KEY,
email text NOT NULL UNIQUE,
full_name text,
created_at timestamptz NOT NULL DEFAULT now()
);
-- Index that PostgreSQL creates automatically because of UNIQUE
-- on email.
-- Equivalent to:
-- CREATE UNIQUE INDEX users_email_key ON users (email);Query:
SELECT * FROM users WHERE email = 'bob@example.com';- With an index on
email, PostgreSQL can use an index scan. - Without that index, it likely uses a sequential scan of all
users.
You can see what PostgreSQL plans to do with EXPLAIN, which we will cover later.
Basic Indexing for Performance
Indexes are usually the most important tool for PostgreSQL performance.
What an Index Is
An index is a data structure that lets PostgreSQL quickly find rows by some column values.
You can think of:
- A table as an unordered box of records.
- An index as a sorted list of pointers into that box.
Most common index type: B-tree index.
It is good for:
- Equality:
= - Inequality:
<,<=,>,>= - Prefix string search:
LIKE 'abc%'
When You Should Add an Index
You should usually have an index on columns that are used often in:
WHEREclausesJOINconditionsORDER BYclauses- Foreign key columns
Example:
CREATE TABLE orders (
id bigserial PRIMARY KEY,
user_id bigint NOT NULL REFERENCES users(id),
total_cents integer NOT NULL,
status text NOT NULL,
created_at timestamptz NOT NULL DEFAULT now()
);
-- Good performance indexes:
CREATE INDEX idx_orders_user_id ON orders (user_id);
CREATE INDEX idx_orders_created_at ON orders (created_at);
CREATE INDEX idx_orders_status ON orders (status);These indexes help queries like:
SELECT * FROM orders WHERE user_id = 123;
SELECT * FROM orders
WHERE created_at >= now() - interval '7 days';
SELECT * FROM orders
WHERE status = 'paid';Tradeoffs of Indexes
Indexes are not free.
- Pros
- Faster reads for matching queries
- Can enforce uniqueness (
UNIQUEindex) - Cons
- Slower writes: every
INSERT,UPDATE, andDELETEmust update indexes - Use extra disk space
- Too many indexes can hurt overall performance
Rule:
Create indexes only for queries that you actually run, and that really benefit from them.
Regularly remove unused or almost unused indexes.
Composite Indexes
A composite index covers multiple columns.
Example:
CREATE INDEX idx_orders_user_id_created_at
ON orders (user_id, created_at);This index helps efficiently with:
-- Uses index: matches (user_id, created_at)
SELECT * FROM orders
WHERE user_id = 123
AND created_at >= now() - interval '7 days';But order of columns matters.
Given ON orders (user_id, created_at):
- Index can help with conditions that start with
user_id, for example: WHERE user_id = ...WHERE user_id = ... AND created_at > ...- It usually does not help with:
WHERE created_at > ...alone
Rule:
In a composite index (a, b, c), the index is most useful when your query filters or sorts starting from a, then b, then c in that order.
Example table to show usage:
| Index definition | Good for WHERE | Not good for WHERE |
|---|---|---|
ON orders (user_id, created_at) | user_id = ?, user_id = ? AND created_at > ? | created_at > ? only |
ON orders (status, created_at) | status = ? | created_at > ? only |
ON orders (created_at, status) | created_at > ? | status = ? only in many cases |
Common Query Patterns and Performance
Some query styles are much more index friendly than others.
Equality and Range Filters
These conditions usually work very well with B-tree indexes:
WHERE user_id = 123
WHERE created_at >= now() - interval '1 day'
WHERE total_cents BETWEEN 1000 AND 5000Combined:
SELECT * FROM orders
WHERE user_id = 123
AND created_at >= now() - interval '30 days';- Composite index on
(user_id, created_at)is ideal here.
LIKE and ILIKE
PostgreSQL can use a regular B-tree index for:
WHERE name LIKE 'Ali%'because it is a prefix search.
It cannot use a B-tree index for:
WHERE name LIKE '%ali%'This usually forces a sequential scan.
To handle such patterns you would use special index types (like GIN with pg_trgm extension). That is more advanced and belongs in a deeper performance chapter. For now, just know:
- Indexes help prefix matches
- Indexes do not help general "contains" matches unless you use special features
Functions in WHERE Clauses
If you wrap the column in a function, PostgreSQL often cannot use a simple index on the column.
Example:
-- Index:
CREATE INDEX idx_users_created_at ON users (created_at);
-- Query:
SELECT * FROM users
WHERE date(created_at) = date(now());Here:
date(created_at)applies a function to the column.- PostgreSQL often cannot use
idx_users_created_atefficiently.
Better:
SELECT * FROM users
WHERE created_at >= date_trunc('day', now())
AND created_at < date_trunc('day', now()) + interval '1 day';
This version compares created_at directly, so the index can be used.
Rule:
To use a normal index, avoid wrapping indexed columns in functions in WHERE or JOIN conditions. Rewrite the condition so the column appears directly on one side of a comparison.
Selecting Only Needed Columns
If you only need a few columns, specify them:
-- Worse:
SELECT * FROM orders WHERE id = 123;
-- Better (if you only need 2 fields):
SELECT id, status FROM orders WHERE id = 123;This reduces:
- Data that must be read from disk
- Data that must be sent over the network
For high traffic APIs, this is significant.
Using EXPLAIN to Understand Query Plans
You do not guess about performance, you inspect how PostgreSQL runs your queries.
EXPLAIN
EXPLAIN shows the plan that PostgreSQL intends to use.
Example table:
CREATE TABLE users (
id bigserial PRIMARY KEY,
email text NOT NULL UNIQUE,
full_name text
);Query:
EXPLAIN
SELECT * FROM users WHERE email = 'alice@example.com';Example output:
Index Scan using users_email_key on users (cost=0.28..8.30 rows=1 width=72)
Index Cond: (email = 'alice@example.com'::text)Explanation:
Index Scan: the operation typeusing users_email_key: the index usedcost=0.28..8.30: planner estimated "cost" units, not actual time
If you see Seq Scan on users:
Seq Scan on users (cost=0.00..45.00 rows=1 width=72)
Filter: (email = 'alice@example.com'::text)then PostgreSQL is scanning the whole table. Maybe there is no index, or it thinks using the index is more "expensive" for some reason.
EXPLAIN ANALYZE
EXPLAIN ANALYZE actually executes the query and shows real timing and row counts.
EXPLAIN ANALYZE
SELECT * FROM users WHERE email = 'alice@example.com';Example:
Index Scan using users_email_key on users (cost=0.28..8.30 rows=1 width=72)
(actual time=0.030..0.031 rows=1 loops=1)
Index Cond: (email = 'alice@example.com'::text)
Planning Time: 0.095 ms
Execution Time: 0.052 msKey fields:
actual time=...: real time spent on that steprows=1: actual rows returned by that stepPlanning Time: how long it took to choose the planExecution Time: total execution time
Warning: EXPLAIN ANALYZE runs the query. Be careful with queries that write data.
A More Complex Example with JOIN
Suppose:
CREATE TABLE users (
id bigserial PRIMARY KEY,
email text NOT NULL UNIQUE,
full_name text
);
CREATE TABLE orders (
id bigserial PRIMARY KEY,
user_id bigint NOT NULL REFERENCES users(id),
total_cents integer NOT NULL,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX idx_orders_user_id ON orders (user_id);Query:
EXPLAIN ANALYZE
SELECT u.email, o.total_cents
FROM users u
JOIN orders o ON o.user_id = u.id
WHERE u.email = 'alice@example.com';Simplified output:
Nested Loop (cost=0.56..12.34 rows=1 width=40)
(actual time=0.050..0.052 rows=1 loops=1)
-> Index Scan using users_email_key on users u
(cost=0.28..8.30 rows=1 width=32)
(actual time=0.030..0.031 rows=1 loops=1)
Index Cond: (email = 'alice@example.com'::text)
-> Index Scan using idx_orders_user_id on orders o
(cost=0.28..4.03 rows=1 width=16)
(actual time=0.017..0.017 rows=1 loops=1)
Index Cond: (user_id = u.id)
Planning Time: 0.150 ms
Execution Time: 0.080 msYou can see:
Nested Loopjoin method.- Two
Index Scanoperations, one for each table. - Very low execution time.
If you see Seq Scan on a large table in your real queries, this is often a good place to start optimizing.
Query Optimization Examples
Here are some typical performance problems and simple PostgreSQL oriented fixes.
Example 1: Missing Index
Problem:
SELECT * FROM orders
WHERE user_id = 123
ORDER BY created_at DESC
LIMIT 10;
If there is no index on user_id, EXPLAIN might show:
Limit
-> Sort
Sort Key: created_at DESC
-> Seq Scan on orders
Filter: (user_id = 123)Fix:
CREATE INDEX idx_orders_user_id_created_at
ON orders (user_id, created_at DESC);After adding the index, EXPLAIN might show:
Limit
-> Index Scan using idx_orders_user_id_created_at on orders
Index Cond: (user_id = 123)Now it can:
- Jump directly to the first matching rows for this user
- Already sorted by
created_at DESC - Stop after 10 rows
Example 2: Avoid `SELECT *` in Large Joins
Query:
SELECT *
FROM orders o
JOIN users u ON u.id = o.user_id
WHERE o.created_at >= now() - interval '1 day';If both tables have many columns, this moves a lot of data.
Better:
SELECT o.id, o.total_cents, o.created_at, u.email
FROM orders o
JOIN users u ON u.id = o.user_id
WHERE o.created_at >= now() - interval '1 day';This reduces memory and network usage, which helps performance under load.
Example 3: Filtering Before Aggregation
Query:
SELECT user_id, SUM(total_cents)
FROM orders
GROUP BY user_id;If you only care about data from last month, do not aggregate all history:
SELECT user_id, SUM(total_cents)
FROM orders
WHERE created_at >= date_trunc('month', now()) - interval '1 month'
AND created_at < date_trunc('month', now())
GROUP BY user_id;Even better if you have an index:
CREATE INDEX idx_orders_created_at ON orders (created_at);Now PostgreSQL can skip old data quickly.
Basic PostgreSQL Configuration for Performance
PostgreSQL has many configuration parameters. For beginners, you usually do not need to touch most of them.
However, a few basic ideas are useful.
Memory: work_mem and shared_buffers
You do not need to tune these precisely in a beginner project, but you should know what they mean.
shared_buffers
Memory PostgreSQL uses to cache table and index data.
For small environments this might be around 128 MB or 256 MB by default.work_mem
Memory used per operation for sorts and hashes.
If it is too small, PostgreSQL will use temporary disk files for sorting large result sets, which is slower.
You can view current values with:
SHOW shared_buffers;
SHOW work_mem;or all settings:
SHOW ALL;For production systems you would adjust them based on available RAM. That topic belongs in a more advanced chapter.
Autovacuum and Bloat
PostgreSQL uses MVCC, which means:
- When you update or delete a row, PostgreSQL creates new versions and marks old ones as dead.
- Dead rows still occupy space until a process called VACUUM cleans them up.
Autovacuum runs automatically in the background.
Key effects:
- If autovacuum is disabled or misconfigured, tables and indexes can become bloated.
- Bloat means:
- Wasted disk space
- Slower queries because PostgreSQL has to scan more pages
You can check if autovacuum is enabled:
SHOW autovacuum;
It is usually on by default and you should keep it on.
You can also manually vacuum if needed:
VACUUM ANALYZE orders;VACUUMreclaims space from dead rows.ANALYZEupdates statistics, which helps the planner choose better query plans.
Rule:
Always keep autovacuum enabled. Run VACUUM ANALYZE on heavily updated tables if you see performance degrade, especially in test or development environments where autovacuum might not run as often.
Statistics and the Query Planner
PostgreSQL decides between an index scan and sequential scan based on statistics about your data.
ANALYZE and Statistics
PostgreSQL stores statistics like:
- How many rows are in each table
- How common certain values are in each column
These statistics are used by the planner to estimate how many rows match a condition.
If statistics are old or missing:
- The planner may choose a bad plan.
- For example, it might think a condition matches many rows when it actually matches few.
ANALYZE updates statistics:
ANALYZE users;
ANALYZE orders;
You do not usually need to run this manually, since autovacuum also runs ANALYZE. But in test environments or after bulk data loads, it is good to know.
Example scenario:
- You bulk insert 10 million rows into
orders. - The planner still thinks the table is small and chooses slow plans.
- After
ANALYZE orders;the planner understands the table size and chooses better plans.
Simple Monitoring for Slow Queries
You cannot improve what you do not measure.
PostgreSQL log_min_duration_statement
PostgreSQL can log any query that takes longer than a given time.
In your postgresql.conf, you can set:
log_min_duration_statement = 200msThis will log every query that takes 200 milliseconds or more.
You can then:
- Look at the log
- Find which SQL statements are slow
- Run
EXPLAIN ANALYZEfor those statements - Add appropriate indexes or rewrite queries
On managed services (like cloud providers), you usually have a UI setting for the slow query threshold.
Basic Steps to Optimize a Slow Query
- Find the slow query
From logs or application monitoring. - EXPLAIN ANALYZE it
See the plan and actual times. - Check for
Seq Scanon large tables
Maybe missing an index. - Check for functions on indexed columns in
WHERE
Rewrite conditions if needed. - Check if you select too many rows or columns
Add filters, add pagination, reduce selected columns. - Add or adjust indexes
Especially for operations that run frequently. - Re-run
EXPLAIN ANALYZE
Confirm improvement.
Practical Checklist for PostgreSQL Performance Basics
Use this checklist when you face performance problems.
| Area | Questions to ask |
|---|---|
| Query shape | Do you really need all rows and all columns? Can you filter or aggregate earlier? |
| Indexes | Is there an index for the columns in WHERE, JOIN, and often ORDER BY? |
| Composite indexes | Is the column order in the composite index aligned with your query predicates? |
| Functions in WHERE | Are you wrapping indexed columns in functions or expressions? |
| EXPLAIN ANALYZE | Is the query using Seq Scan on big tables? Where is most time spent? |
| Statistics | Did you recently insert or change a lot of data? Should you run ANALYZE? |
| Vacuum | Could table or index bloat be an issue? Is autovacuum enabled? |
| Logging | Do you log slow queries with log_min_duration_statement? |
If you follow these basic steps, you get a long way in keeping PostgreSQL fast for typical backend applications, especially during early growth of your projects.
Views: 6
KAHIBARO