PostgreSQL is a powerful database capable of handling applications with millions or even billions of records. However, achieving excellent performance requires more than adding CPU cores, increasing memory, or upgrading server hardware.
In many cases, the biggest performance improvements come from writing efficient SQL queries, choosing appropriate indexes, understanding query execution plans, and managing database resources effectively.
Here are 25 practical PostgreSQL techniques that backend developers can use to build faster, more reliable, and scalable applications.
1. Avoid SELECT *
Selecting every column when you need only a few wastes resources and transfers unnecessary data.
Instead of:
SELECT *
FROM users
WHERE id = 123;
Use:
SELECT name, email
FROM users
WHERE id = 123;
Selecting specific columns reduces data transfer and may allow PostgreSQL to use an index-only scan when a suitable index and visibility information are available.
2. Use EXPLAIN ANALYZE to Understand Query Performance
Never optimize a query based solely on intuition. Examine how PostgreSQL executes it.
EXPLAIN ANALYZE
SELECT name, email
FROM users
WHERE id = 123;
Pay attention to:
- Sequential scans and index scans
- Nested loops, hash joins, and merge joins
- Estimated versus actual row counts
- Rows removed by filters
- Execution time and buffer usage
The execution plan helps identify where a query spends its time and which changes are worth testing.
Note: EXPLAIN ANALYZE actually executes the query. Use caution with statements that modify data.
3. Remember That Indexes Have Costs
Indexes can accelerate queries, but maintaining them consumes storage and adds overhead to
INSERT,
UPDATE, and
DELETE operations.
Before creating an index, identify the queries it will support. Also, investigate indexes that consume space without providing measurable benefits.
PostgreSQL's
pg_stat_user_indexes view can help you evaluate index usage. Interpret its statistics carefully, particularly after database restarts or resets.
4. Choose Composite Index Column Order Carefully
The order of columns in a composite index affects which queries can use it efficiently.
For example, consider queries that filter by organization and status:
WHERE org_id = 10
AND status = 'ACTIVE';
A suitable index might be:
CREATE INDEX idx_users_org_status
ON users (org_id, status);
This index can support lookups using both columns and queries filtering on the leading
org_id column.
If many queries filter only by
status, a different index may be necessary.
Choose the order based on actual query patterns, selectivity, and sorting requirements rather than following a universal rule.
5. Use Partial Indexes for Frequently Queried Subsets
A partial index contains entries only for rows that satisfy a specified condition.
For example, if an application frequently retrieves pending orders, consider:
CREATE INDEX idx_pending_orders
ON orders (created_at)
WHERE status = 'PENDING';
This can be smaller and cheaper to maintain than indexing every order.
Partial indexes are particularly useful when the indexed subset represents a relatively small portion of a large table. Queries must have conditions that allow PostgreSQL to establish that the partial-index predicate applies.
6. Use Covering Indexes When Appropriate
A covering index stores the information required by certain queries, potentially allowing PostgreSQL to retrieve results without accessing the table's heap.
CREATE INDEX idx_users_email
ON users (email)
INCLUDE (name);
A query requesting only
email and
name may benefit from an index-only scan.
However, index-only scans also depend on PostgreSQL's visibility map. Covering indexes should be introduced when their read benefits justify their additional storage and write overhead.
7. Prefer Keyset Pagination for Large Datasets
Traditional pagination with
OFFSET becomes increasingly expensive as the offset grows.
SELECT id, name
FROM users
ORDER BY id
LIMIT 20 OFFSET 500000;
PostgreSQL may need to process many rows before returning the requested page.
For sequential pagination, use the last-seen key:
SELECT id, name
FROM users
WHERE id > :last_seen_id
ORDER BY id
LIMIT 20;
This approach is called
keyset pagination. With a suitable index, it can provide much more consistent performance for deep pagination.
For descending order or non-unique sort columns, use an appropriate cursor condition that includes a unique tie-breaker.
8. Consider Time-Ordered Identifiers for Write-Heavy Tables
Random UUIDv4 values can cause less-localized index insertions than sequential identifiers, potentially increasing page splits and reducing cache locality.
If your application needs globally unique identifiers, consider time-ordered alternatives such as UUIDv7.
These identifiers can improve insertion locality while retaining the advantages of UUIDs. However, identifier choice also affects privacy, distributed-system requirements, and compatibility, so benchmark the options for your workload.
9. Use JSONB Without Abandoning Relational Design
PostgreSQL's
JSONB type is useful for flexible metadata, configuration, feature flags, and attributes that vary between records.
It should not automatically replace a well-designed relational schema.
When queries frequently filter or search JSONB fields, consider suitable GIN indexes or expression indexes. Test the actual query patterns because indexing every JSON field can increase storage and maintenance costs.
Keep frequently queried, strongly structured business data in relational columns when that makes constraints and queries simpler.
10. Keep Autovacuum Healthy
PostgreSQL uses multiversion concurrency control (MVCC). Updates and deletes can leave dead row versions that eventually need cleanup.
If vacuuming cannot keep up, table and index bloat may increase, and performance can deteriorate.
PostgreSQL's autovacuum normally handles routine maintenance. Monitor its activity, dead-tuple estimates, transaction age, and long-running transactions to identify situations where it falls behind.
Do not disable autovacuum as a general performance optimization.
11. Use Connection Pooling
Creating and managing large numbers of PostgreSQL connections consumes resources. Each ordinary client connection typically requires a separate server process.
Connection pooling can reduce connection overhead and help applications handle concurrent requests more efficiently.
Common options include:
- PgBouncer
- Managed database connection proxies
- Application-level pools, such as
pgxpool for Go
Configure pool sizes according to the database's capacity and the application's concurrency requirements. More connections do not necessarily mean more throughput.
12. Batch Multiple Inserts
Sending thousands of individual insert statements creates unnecessary network round trips and transaction overhead.
Instead, use a multi-row insert:
INSERT INTO events (event_type, user_id)
VALUES
('login', 101),
('logout', 102),
('login', 103);
Batching reduces communication overhead and often improves throughput.
For very large batches, consider PostgreSQL's
COPY command.
13. Use COPY for Bulk Data Loading
When importing large datasets, PostgreSQL's
COPY command is generally more efficient than issuing individual insert statements.
For example:
COPY events (event_type, user_id)
FROM STDIN
WITH (FORMAT CSV, HEADER TRUE);
This example assumes the incoming CSV data contains a header and that the client supplies the data through the
STDIN protocol.
COPY is particularly useful for data migrations, analytics imports, ETL pipelines, and large initial data loads.
14. Introduce Partitioning When It Solves a Real Problem
Partitioning divides a large logical table into smaller physical partitions, often based on a date, tenant key, or another useful column.
Benefits can include:
- Partition pruning for queries that filter on partition keys
- Faster removal of old data by dropping or detaching partitions
- More manageable maintenance for certain workloads
Partitioning introduces additional design and operational complexity. Start with a well-indexed table and introduce partitions when data volume, query patterns, or maintenance requirements justify them.
15. Do Not Automatically Reject Sequential Scans
A sequential scan is not necessarily a performance problem.
When PostgreSQL expects to retrieve a large percentage of a table, reading the table sequentially can be cheaper than repeatedly accessing individual rows through an index.
The optimizer considers estimated row counts, table size, available indexes, and other factors when choosing a plan.
Investigate the actual execution time and workload before trying to force index usage.
16. Eliminate N+1 Query Problems
An N+1 problem occurs when an application retrieves a collection of records and then issues another query for each record.
For example, loading 100 orders and querying each order's customer separately can produce 101 database queries.
Depending on the data model, a join can retrieve the necessary information in one query:
SELECT
o.id AS order_id,
c.name AS customer_name
FROM orders o
JOIN customers c
ON c.id = o.customer_id;
Other solutions include batching, carefully designed subqueries, and application-level eager loading.
Reducing unnecessary round trips often improves response times substantially.
17. Use Window Functions for Analytical Operations
Window functions allow PostgreSQL to perform ranking, running totals, comparisons, and other calculations across related rows.
For example, to rank orders within each customer:
SELECT
customer_id,
id AS order_id,
amount,
ROW_NUMBER() OVER (
PARTITION BY customer_id
ORDER BY amount DESC, id
) AS order_rank
FROM orders;
Useful functions include:
ROW_NUMBER() and RANK()
LAG() and LEAD()
SUM() OVER (...)
AVG() OVER (...)
Window functions can replace complicated application-side loops and multiple queries, although their performance still depends on sorting, partition sizes, indexes, and execution plans.
18. Understand Common Table Expression Materialization
Common table expressions (CTEs) can make complex SQL easier to read and maintain.
In PostgreSQL 12 and later, eligible CTEs can often be inlined into the surrounding query rather than being materialized separately.
You can influence this behavior with:
WITH active_users AS NOT MATERIALIZED (
SELECT id, name
FROM users
WHERE status = 'ACTIVE'
)
SELECT id, name
FROM active_users
WHERE id > 100;
MATERIALIZED can request separate calculation and storage of a CTE's result, while
NOT MATERIALIZED can allow further optimization through inlining when supported.
Neither choice is universally faster. Test both when a CTE appears in a costly execution plan.
19. Refresh Statistics After Significant Data Changes
PostgreSQL relies on statistics to estimate how many rows a query will return and choose an execution plan.
After a substantial data load or other major change, refresh statistics when needed:
ANALYZE;
You can also analyze a specific table:
ANALYZE orders;
Fresh statistics can help the optimizer choose better join strategies and access paths.
If estimates remain inaccurate, investigate data distribution, column correlations, and whether higher statistics targets are appropriate.
20. Enable pg_stat_statements
The
pg_stat_statements extension tracks aggregated planning and execution statistics for SQL statements.
It can help identify queries with:
- High total execution time
- High mean execution time
- Excessive call counts
- Significant resource consumption
Once the extension is installed and configured, use its statistics to prioritize optimization work.
For example, a moderately expensive query executed thousands of times may deserve more attention than a slow query that runs once a day.
21. Build Concurrent Job Queues with FOR UPDATE SKIP LOCKED
PostgreSQL can support database-backed job queues when workers need to claim different pending jobs concurrently.
A simplified example is:
SELECT id, created_at
FROM jobs
WHERE status = 'PENDING'
ORDER BY created_at, id
LIMIT 10
FOR UPDATE SKIP LOCKED;
This query skips rows locked by other transactions rather than waiting for them.
A complete job-claiming implementation should run inside a transaction, update the selected jobs to mark them as claimed, and commit promptly. Appropriate indexes and recovery handling are also important.
This pattern is useful for moderate workloads, although specialized messaging systems may be better for workloads requiring advanced delivery guarantees or very high throughput.
22. Choose Appropriate Data Types
Good data modeling prevents avoidable bugs and reduces unnecessary complexity.
Consider these practices:
- Use
timestamptz for real-world timestamps. It represents an instant in time and handles time-zone conversions appropriately.
- Use
text when a fixed maximum length is not a business requirement. PostgreSQL does not generally provide a performance advantage for varchar(n) over text.
- Choose integer types based on expected ranges. Use
bigint when values may exceed the capacity of a 32-bit integer.
- Use
numeric for exact decimal arithmetic. It is often appropriate for financial amounts where rounding precision matters.
Also define suitable constraints, nullability, and defaults. Correct data types make applications easier to maintain and safer to evolve.
23. Configure Query and Transaction Timeouts
Runaway queries, excessive lock waits, and abandoned open transactions can affect the whole application.
PostgreSQL provides several settings to limit these risks:
SET statement_timeout = '30s';
SET lock_timeout = '5s';
SET idle_in_transaction_session_timeout = '60s';
These settings limit statement execution time, lock acquisition waits, and idle time inside an open transaction, respectively.
The values above are examples, not universal recommendations. Choose limits based on the application's workload and configure appropriate defaults at the database, role, or application level.
24. Use LATERAL Joins for Top-N Queries per Group
A
LATERAL join lets a subquery reference columns from an earlier
FROM item. This is useful when each parent row needs its own related result set.
For example, retrieve the three most recent orders for each customer:
SELECT
c.id AS customer_id,
c.name,
o.id AS order_id,
o.created_at
FROM customers c
CROSS JOIN LATERAL (
SELECT id, created_at
FROM orders
WHERE customer_id = c.id
ORDER BY created_at DESC, id DESC
LIMIT 3
) o;
An index such as the following may help:
CREATE INDEX idx_orders_customer_created
ON orders (customer_id, created_at DESC, id DESC);
Because this example uses
CROSS JOIN LATERAL, customers without matching orders are excluded. Use
LEFT JOIN LATERAL (...) ON TRUE if those customers should also appear.
25. Measure Every Optimization
The final and most important technique is to validate every performance change.
Use a repeatable process:
- Identify a measurable performance problem.
- Capture the query plan and baseline execution time.
- Change one relevant variable.
- Run representative tests under comparable conditions.
- Compare execution time, resource usage, and application behavior.
- Keep the change only if the evidence supports it.
Use
EXPLAIN ANALYZE, buffer statistics where appropriate, application metrics, and representative production-like data.
A change that improves one isolated query may negatively affect writes or other queries. Evaluate the broader workload before deploying it.
The Most Important PostgreSQL Optimization Skill
If there is one skill every backend engineer should develop, it is the ability to read and understand PostgreSQL execution plans.
Indexes, partitioning, connection pooling, and query rewrites are useful tools, but none guarantees better performance in every situation.
Understanding why PostgreSQL selected a particular plan helps you distinguish genuine bottlenecks from harmless details and choose optimizations based on evidence.
Conclusion
PostgreSQL can support demanding applications at substantial scale without requiring an immediate move to a different database system.
The key is to focus on the fundamentals: efficient queries, carefully designed indexes, accurate statistics, healthy vacuuming, sensible connection management, appropriate data types, and repeatable performance testing.
Avoid introducing complexity before the workload requires it. Measure first, make targeted changes, and verify the results.
With these techniques, backend developers can build PostgreSQL applications that are faster, more predictable, and easier to maintain as their data and user bases grow.