SQL Query Optimization: A Practical Guide

Your query takes 4 seconds. It should take 40 milliseconds. This guide shows you how to diagnose slow queries with EXPLAIN, choose the right indexes, fix N+1 problems, optimize JOINs, implement efficient pagination, and decide when caching is the right answer.

Diagnosing Slow Queries

Before you can optimize a query, you need to know which queries are slow and why. Every major database has tools for this.

Finding Slow Queries

PostgreSQL: Enable the pg_stat_statements extension. It tracks every query, its total execution time, number of calls, and average time. Sort by total_exec_time to find your biggest offenders.

sql — PostgreSQL: Find Slowest Queries
SELECT query,
       calls,
       total_exec_time / 1000 AS total_seconds,
       mean_exec_time AS avg_ms,
       rows
FROM pg_stat_statements
ORDER BY total_exec_time DESC
LIMIT 20;

MySQL: Enable the slow query log with slow_query_log = 1 and long_query_time = 1 (in seconds) in your configuration. Every query that exceeds the threshold gets logged. Use mysqldumpslow to summarize the log.

sql — MySQL: Enable Slow Query Log
SET GLOBAL slow_query_log = 1;
SET GLOBAL long_query_time = 1;  -- Log queries slower than 1 second
SET GLOBAL log_queries_not_using_indexes = 1;  -- Also log full table scans

Once you know which queries are slow, the next step is always the same: run EXPLAIN.

Reading EXPLAIN Plans

EXPLAIN shows you the database's execution plan — how it reads data, which indexes it uses, and the estimated cost of each operation. It is the single most important tool for SQL optimization.

PostgreSQL: EXPLAIN ANALYZE

sql — PostgreSQL EXPLAIN ANALYZE
EXPLAIN ANALYZE
SELECT u.name, COUNT(o.id) AS order_count
FROM users u
JOIN orders o ON o.user_id = u.id
WHERE u.created_at > '2025-01-01'
GROUP BY u.name
ORDER BY order_count DESC
LIMIT 10;

The output shows a tree of operations. Here is what to look for:

What You See What It Means Action
Seq Scan on large table Full table scan (reading every row) Add an index on the filtered/joined column
Index Scan or Index Only Scan Using an index (good) No action needed — Index Only Scan is the best case
Nested Loop with high row count For each row in table A, scan table B Check for missing index on the inner table
Hash Join Build hash table from smaller table, probe with larger Usually efficient for large joins
Sort with high cost Sorting in memory or on disk Add an index that matches the ORDER BY
Rows (estimated) far from Rows (actual) Stale statistics Run ANALYZE tablename;

MySQL: EXPLAIN FORMAT=JSON

sql — MySQL EXPLAIN
EXPLAIN FORMAT=JSON
SELECT * FROM orders
WHERE customer_id = 42
  AND status = 'shipped'
ORDER BY created_at DESC
LIMIT 10;

In MySQL, look for "type": "ALL" (full table scan), "type": "ref" or "type": "range" (index used), and "using_filesort": true (sorting without an index). Format your SQL for readability before analyzing it — the SQL Formatter handles indentation, keyword casing, and alignment for complex queries.

Indexing Strategies

An index is a separate data structure (usually a B-tree) that lets the database find rows without scanning the entire table. Think of it as the index at the back of a textbook — instead of reading every page, you look up the term and jump to the right page.

When to Add an Index

Composite Indexes

A composite (multi-column) index is often more effective than multiple single-column indexes. Column order matters: the index can be used for queries that filter on the leftmost columns.

sql — Composite Index
-- Query pattern: filter by status, then sort by created_at
SELECT * FROM orders
WHERE status = 'pending'
ORDER BY created_at DESC
LIMIT 20;

-- Index that covers both the filter and the sort
CREATE INDEX idx_orders_status_created
ON orders (status, created_at DESC);

This single index lets the database filter by status = 'pending' and return results already sorted by created_at DESC, eliminating both the table scan and the sort operation.

Covering Indexes

A covering index includes all the columns a query needs, so the database can answer the query entirely from the index without touching the table. In PostgreSQL, this shows as "Index Only Scan" in the EXPLAIN output.

sql — Covering Index
-- Query: only needs id and email
SELECT id, email FROM users WHERE status = 'active';

-- Covering index: includes all columns the query reads
CREATE INDEX idx_users_status_covering
ON users (status) INCLUDE (id, email);
Index Trade-offs

Every index speeds up reads but slows down writes. Each INSERT, UPDATE, or DELETE must also update every index on the table. On write-heavy tables, adding too many indexes can make performance worse overall. Measure both read and write performance after adding an index.

Optimizing JOINs

JOINs are the most expensive operation in most queries. Here are the key optimization strategies.

1. Index Both Sides of the JOIN

sql — Indexed JOIN
-- Ensure indexes exist on both sides
CREATE INDEX idx_orders_customer_id ON orders (customer_id);
-- customers.id is already indexed (primary key)

SELECT c.name, o.total
FROM customers c
JOIN orders o ON o.customer_id = c.id
WHERE c.country = 'US';

2. Filter Before Joining

Reduce the number of rows before the JOIN executes. The optimizer often does this automatically, but explicit subqueries or CTEs can help with complex queries.

sql — Filter First, Then Join
-- Instead of joining all orders then filtering
SELECT c.name, o.total
FROM customers c
JOIN orders o ON o.customer_id = c.id
WHERE o.created_at > '2025-01-01'
  AND c.country = 'US';

-- With CTEs for clarity (same performance in most cases)
WITH recent_orders AS (
  SELECT customer_id, total
  FROM orders
  WHERE created_at > '2025-01-01'
),
us_customers AS (
  SELECT id, name
  FROM customers
  WHERE country = 'US'
)
SELECT uc.name, ro.total
FROM us_customers uc
JOIN recent_orders ro ON ro.customer_id = uc.id;

3. Use EXISTS Instead of JOIN for Existence Checks

sql — EXISTS vs JOIN
-- Slower: JOIN fetches all matching rows, then deduplicates
SELECT DISTINCT c.name
FROM customers c
JOIN orders o ON o.customer_id = c.id;

-- Faster: EXISTS stops after finding the first match
SELECT c.name
FROM customers c
WHERE EXISTS (
  SELECT 1 FROM orders o WHERE o.customer_id = c.id
);

The N+1 Query Problem

The N+1 problem is the most common performance issue in web applications that use ORMs. It happens when code fetches a list of N items, then runs a separate query for each item's related data.

pseudocode — N+1 Problem
// 1 query: fetch all orders
orders = db.query("SELECT * FROM orders LIMIT 100")

// 100 queries: fetch customer for each order (BAD)
for order in orders:
    customer = db.query("SELECT * FROM customers WHERE id = ?", order.customer_id)
    print(order.id, customer.name)

// Total: 101 queries for 100 orders

Fix 1: Use a JOIN

sql — Single Query with JOIN
SELECT o.id, o.total, c.name AS customer_name
FROM orders o
JOIN customers c ON c.id = o.customer_id
LIMIT 100;
-- 1 query instead of 101

Fix 2: Batch with IN

sql — Batch Query
-- Query 1: fetch orders
SELECT * FROM orders LIMIT 100;

-- Query 2: fetch all related customers at once
SELECT * FROM customers WHERE id IN (1, 2, 3, ... 100);
-- 2 queries instead of 101

Fix 3: ORM Eager Loading

code — ORM Solutions
// Prisma (JavaScript/TypeScript)
const orders = await prisma.order.findMany({
  include: { customer: true },
  take: 100,
});

// SQLAlchemy (Python)
orders = session.query(Order).options(joinedload(Order.customer)).limit(100).all()

// ActiveRecord (Ruby)
orders = Order.includes(:customer).limit(100)

If you are converting query results between formats, the JSON to CSV Converter can help when exporting database data for analysis in spreadsheets.

SELECT Only What You Need

SELECT * is the lazy option that costs real performance. It fetches every column, including large TEXT/BLOB fields you might not need, wastes network bandwidth between the database and your application, and prevents the use of covering indexes.

sql — Specific Columns
-- Bad: fetches all 30 columns including the large bio TEXT field
SELECT * FROM users WHERE status = 'active';

-- Good: fetches only the 3 columns you actually display
SELECT id, name, email FROM users WHERE status = 'active';

This matters most for queries that return many rows (lists, feeds, exports) and tables with wide schemas or large column values.

Efficient Pagination

OFFSET Pagination (Simple but Slow)

sql — OFFSET Pagination
-- Page 1
SELECT * FROM posts ORDER BY created_at DESC LIMIT 20 OFFSET 0;

-- Page 500 (slow: database scans and discards 9,980 rows)
SELECT * FROM posts ORDER BY created_at DESC LIMIT 20 OFFSET 9980;

The problem: the database must read and discard all rows before the offset. At OFFSET 100,000, it reads 100,000 rows just to throw them away.

Cursor Pagination (Fast at Any Depth)

sql — Cursor Pagination
-- First page
SELECT id, title, created_at
FROM posts
ORDER BY created_at DESC, id DESC
LIMIT 20;

-- Next page: use the last item's values as cursor
SELECT id, title, created_at
FROM posts
WHERE (created_at, id) < ('2026-02-10 14:30:00', 4582)
ORDER BY created_at DESC, id DESC
LIMIT 20;

The WHERE clause jumps directly to the right position using the index. Whether you are on page 2 or page 5,000, the query is equally fast. Use a composite index on (created_at DESC, id DESC) to support this pattern.

When to Use Which

Cursor pagination: APIs, infinite scroll, mobile apps, any case where users paginate sequentially. OFFSET pagination: Admin dashboards with small datasets, or when random page access (jump to page 7) is required.

Subqueries vs JOINs vs CTEs

Three ways to combine data from multiple tables. Here is when to use each.

Approach Best For Watch Out
JOIN Combining rows from related tables Can produce duplicates with one-to-many relationships
Correlated Subquery EXISTS checks, scalar aggregates per row Runs once per outer row — can be slow on large result sets
CTE (WITH) Breaking complex queries into readable steps In MySQL <8.0, CTEs are materialized (no optimization through them)
Derived Table Pre-aggregating data before a JOIN Optimizer can usually push predicates into derived tables
sql — CTE for Readability
WITH monthly_totals AS (
  SELECT customer_id,
         DATE_TRUNC('month', created_at) AS month,
         SUM(total) AS monthly_spend
  FROM orders
  WHERE created_at >= '2025-01-01'
  GROUP BY customer_id, DATE_TRUNC('month', created_at)
),
high_spenders AS (
  SELECT customer_id
  FROM monthly_totals
  WHERE monthly_spend > 1000
  GROUP BY customer_id
  HAVING COUNT(*) >= 3  -- At least 3 months over $1000
)
SELECT c.name, c.email
FROM customers c
JOIN high_spenders hs ON hs.customer_id = c.id;

When migrating data between SQL databases and NoSQL systems, the SQL to MongoDB Query Converter translates your SQL queries into MongoDB's query syntax.

When to Cache

Caching is not a substitute for query optimization. A cached slow query is still a slow query — it just fails less often. Optimize the query first, then add caching for frequently-accessed data that changes infrequently.

Good Candidates for Caching

Bad Candidates for Caching

pseudocode — Cache-Aside Pattern
function getTopProducts() {
  // 1. Check cache
  cached = redis.get("top_products")
  if (cached) return JSON.parse(cached)

  // 2. Cache miss: run query
  result = db.query(`
    SELECT p.name, SUM(oi.quantity) AS total_sold
    FROM products p
    JOIN order_items oi ON oi.product_id = p.id
    WHERE oi.created_at > NOW() - INTERVAL '7 days'
    GROUP BY p.name
    ORDER BY total_sold DESC
    LIMIT 10
  `)

  // 3. Store in cache with TTL
  redis.setex("top_products", 300, JSON.stringify(result))  // 5 min TTL
  return result
}

To inspect and debug the JSON payloads returned by your cache, the JSON Viewer renders them in an expandable tree with syntax highlighting.

Optimization Checklist

Run through this checklist whenever you encounter a slow query.

  1. Run EXPLAIN ANALYZE — understand the execution plan before changing anything
  2. Check for Seq Scans — add indexes on filtered, joined, and sorted columns
  3. Check for N+1 patterns — use JOINs or batch queries instead of loops
  4. Select only needed columns — replace SELECT * with explicit column lists
  5. Check statistics freshness — run ANALYZE on tables with outdated stats
  6. Review JOIN order and conditions — ensure indexes exist on both sides
  7. Use cursor pagination — replace OFFSET with WHERE-based cursors for large datasets
  8. Consider covering indexes — INCLUDE columns to avoid table lookups
  9. Evaluate caching — only after the query itself is optimized
  10. Test with production-size data — queries that are fast on 100 rows may be slow on 10 million

Format, Convert, and Analyze SQL

QTool's SQL tools help you format complex queries, convert between database syntaxes, and work with JSON data — all in the browser, no signup needed.

Open SQL Formatter JSON Formatter

SQL Developer Tools


Frequently Asked Questions

Run EXPLAIN (or EXPLAIN ANALYZE in PostgreSQL) before your query to see the execution plan. The plan shows how the database reads data: whether it uses indexes or scans entire tables, which join algorithm it picks, and the estimated cost and row counts for each step. Look for Sequential Scans (Seq Scan) on large tables, which usually mean a missing index. Check the estimated rows versus actual rows; a large mismatch indicates stale statistics (run ANALYZE). In MySQL, use EXPLAIN FORMAT=JSON for detailed cost breakdowns. In most cases, the first optimization is adding an index on the columns used in WHERE, JOIN, and ORDER BY clauses.

The N+1 problem occurs when your code runs 1 query to fetch a list of N records, then runs N additional queries to fetch related data for each record individually. For example, fetching 100 orders and then running a separate query for each order's customer data results in 101 queries. Fix it by using a JOIN to fetch everything in one query, or by using an IN clause to batch the related lookups into a single query (SELECT * FROM customers WHERE id IN (1, 2, 3, ...)). Most ORMs like Prisma, SQLAlchemy, and ActiveRecord have eager loading features (include, joinedload, includes) that solve this automatically. N+1 is the single most common cause of slow API endpoints in web applications.

Add an index when a column or combination of columns is frequently used in WHERE clauses, JOIN conditions, or ORDER BY clauses, and the table has enough rows that scanning it is noticeably slow (typically over a few thousand rows). Do not index every column. Each index adds overhead to INSERT, UPDATE, and DELETE operations because the database must maintain the index data structure alongside the table. Composite indexes (on multiple columns) are more efficient than multiple single-column indexes for queries that filter on several columns. The column order in a composite index matters: put the most selective column (the one that filters out the most rows) first. Use EXPLAIN to verify that your queries actually use the indexes you create.

OFFSET pagination uses LIMIT and OFFSET (e.g., SELECT * FROM posts ORDER BY id LIMIT 20 OFFSET 1000) to skip rows. It is simple but gets slower as the offset increases because the database must scan and discard all skipped rows. At OFFSET 100000 on a large table, performance degrades significantly. Cursor pagination (also called keyset pagination) uses a WHERE clause on an indexed column to start from the last seen value (e.g., SELECT * FROM posts WHERE id > 1000 ORDER BY id LIMIT 20). This is consistently fast regardless of how deep you paginate because it uses the index to jump directly to the right position. Use cursor pagination for any paginated API or infinite scroll. Use OFFSET only for small datasets or when you need random page access (page 1, page 5, page 3).

No. SELECT * fetches all columns from a table, including large text fields, blobs, and columns you do not need. This wastes memory, network bandwidth, and prevents the database from using covering indexes (indexes that contain all the requested columns, allowing the query to be answered entirely from the index without reading the table). Always specify only the columns you need: SELECT id, name, email FROM users. This is especially important for tables with many columns or large column values, and for queries that return many rows. The only acceptable use of SELECT * is during ad-hoc exploration in a database client, never in application code.

First, ensure both sides of the JOIN condition have indexes. A JOIN on orders.customer_id = customers.id needs an index on orders.customer_id and customers.id (the primary key index covers the latter automatically). Second, filter early: add WHERE conditions to reduce the number of rows before the JOIN executes, not after. Third, avoid joining on expressions or functions (e.g., JOIN ON LOWER(a.name) = LOWER(b.name)) because this prevents index usage. Fourth, for queries that only need data from one side of the join, consider using EXISTS instead of JOIN, as it can short-circuit evaluation. Fifth, review the EXPLAIN plan to check which join algorithm the optimizer chose (Nested Loop, Hash Join, Merge Join). Hash Joins are efficient for large unsorted datasets, while Merge Joins work best on pre-sorted data.

NT

Christian Bucher

We build free developer tools for SQL, JSON, APIs, and more. 269 tool pages, all browser-based, no signup required.

269 Developer Tools, One Place

Browse 269 indexed tool pages with no QTool account required, and inspect the source on GitHub.

Open Source — Free Forever Try Free Tools

Related Tools

CSS Box Shadow Generator · Emoji Picker & Search · Free CSV to JSON Converter

Related Tools

Free Git Diff Viewer · Visual JSON Editor - Tree View & Raw Editor · Free JSON to YAML Converter

Related Articles

Built by Miguel

Need a custom tool or website?

From . Delivered in 24-48h. You own the code.

View Services →