Every database practitioner has faced the same moment: you add an index, the query runs faster for a week, and then performance degrades again. Indexes are the first line of defense, but they are not a silver bullet. When queries remain slow despite proper indexing, the bottleneck often lies in the query itself, the schema design, or the way the database engine processes the data. This guide explores advanced optimization techniques that go beyond indexing, focusing on workflow and process comparisons at a conceptual level. We will look at execution plan analysis, query restructuring, statistics management, and concurrency tuning—each with concrete steps and trade-offs.
Our goal is to give you a systematic way to diagnose and fix performance issues that indexes alone cannot solve. By the end, you should be able to identify when to rewrite a query, when to adjust database configuration, and when to consider structural changes like partitioning or materialized views. These techniques apply broadly across relational databases, though we will note engine-specific nuances where relevant.
Why Indexing Falls Short and What to Do About It
Indexes accelerate data retrieval by reducing the number of rows the database must scan. But they come with costs: write overhead, storage bloat, and maintenance complexity. More critically, indexes cannot fix fundamentally inefficient query logic. A query that fetches too many columns, joins tables in the wrong order, or triggers a full table scan despite an index will still perform poorly. In many projects, teams invest heavily in indexing while overlooking query patterns that defeat index usage.
Common Scenarios Where Indexes Fail
One typical case is a query with a WHERE clause that applies a function to an indexed column—for example, WHERE DATE(created_at) = '2024-01-01'. The database cannot use a standard B-tree index on created_at because it must evaluate the function for every row. Another scenario involves queries that select a large percentage of rows from a table; the optimizer may choose a full table scan over an index even when an index exists, because random I/O for many rows is slower than a sequential scan. Also, queries with complex joins and subqueries can produce execution plans that the optimizer misjudges due to outdated statistics or correlated data.
The Real Cost of Over-Indexing
Adding indexes to every column that appears in a WHERE clause can backfire. Each index slows down writes—inserts, updates, deletes—because the database must maintain the index structure. On high-write tables, the overhead can become the primary bottleneck. Moreover, the query planner must consider more indexes, increasing planning time. We have seen cases where dropping unused indexes improved overall throughput by 30% because write contention dropped.
What usually breaks first is the assumption that more indexes always help. The better approach is to analyze the query execution plan, understand the data distribution, and then decide whether to rewrite the query, change the schema, or add a targeted index. This workflow—plan analysis first, index changes second—saves time and avoids unintended consequences.
Execution Plan Analysis: The Foundation of Advanced Optimization
Before changing anything, we must understand what the database engine actually does with our query. Execution plans reveal the steps the optimizer chooses: which indexes are used, how tables are joined, how rows are filtered, and where the most time is spent. Without this insight, optimization is guesswork.
Reading Execution Plans Effectively
Most databases provide tools to view execution plans—EXPLAIN ANALYZE in PostgreSQL, SHOW PLAN in SQL Server, and EXPLAIN in MySQL. The key is to look for sequential scans on large tables, high row estimates compared to actual rows, and nested loop joins that process many rows. In PostgreSQL, the EXPLAIN ANALYZE output includes actual timing and row counts, making it easier to spot discrepancies.
A common workflow is to run the query with EXPLAIN ANALYZE, note the total execution time, then drill into each node. Look for nodes with high cost percentages—these are the bottlenecks. For example, if a sequential scan on a 10-million-row table accounts for 80% of the time, an index might help, but also consider whether the query could be rewritten to limit the rows scanned.
When the Optimizer Gets It Wrong
Even with accurate statistics, the optimizer can choose a suboptimal plan. This often happens when the query has correlated subqueries, complex OR conditions, or joins on columns with skewed data distributions. For instance, a query that filters on a column where 99% of rows satisfy the condition might be better served by a full scan than an index, but the optimizer might still choose an index based on generic statistics. In such cases, query hints or plan guides can force a better plan, but they should be used sparingly—they can become a maintenance burden.
The honest limit is that execution plans only show what the database plans to do, not why it made that choice. For deeper understanding, we rely on statistics and query rewriting.
Query Restructuring: Rewriting for Efficiency
Rewriting a query can yield dramatic performance gains without schema changes. The goal is to help the optimizer choose a better plan by simplifying logic, reducing data volume, or enabling more efficient join orders.
Eliminating Unnecessary Work
One of the simplest optimizations is to select only the columns you need. SELECT * forces the database to read all columns, increasing I/O and memory use. Similarly, avoid fetching rows you will filter later—push predicates as deep as possible. For example, instead of SELECT * FROM (SELECT * FROM orders WHERE status = 'active') sub WHERE sub.total > 100, write SELECT * FROM orders WHERE status = 'active' AND total > 100. The database can use indexes on both columns if they exist.
Using Joins Instead of Subqueries
In many databases, joins are optimized better than correlated subqueries. A correlated subquery like SELECT * FROM customers WHERE (SELECT COUNT(*) FROM orders WHERE orders.customer_id = customers.id) > 5 can be rewritten as SELECT customers.* FROM customers JOIN (SELECT customer_id, COUNT(*) as cnt FROM orders GROUP BY customer_id) sub ON customers.id = sub.customer_id WHERE sub.cnt > 5. The join version allows the optimizer to use indexes on orders.customer_id and perform a hash join instead of a nested loop for each customer.
Breaking Down Complex Queries
Sometimes a single query tries to do too much. Splitting it into multiple steps, using temporary tables or common table expressions (CTEs), can improve both readability and performance. For example, a report query that aggregates data from several tables and then applies additional filters might be faster if you first aggregate into a CTE, then filter. However, CTEs in some databases act as optimization fences, meaning the database may not push predicates into them. Always test both versions.
We recommend a process: start with the original query, measure its performance, then apply one rewrite at a time, measuring each change. This isolates the impact and prevents over-optimization.
Worked Example: Optimizing a Slow Reporting Query
Let us walk through a realistic scenario. Imagine a sales database with tables: orders (10 million rows), order_items (30 million rows), and customers (500k rows). The reporting team runs this query daily to find high-value customers:
SELECT c.name, SUM(oi.quantity * oi.price) as total_spent
FROM customers c
JOIN orders o ON c.id = o.customer_id
JOIN order_items oi ON o.id = oi.order_id
WHERE o.order_date >= '2024-01-01' AND o.order_date < '2024-07-01'
GROUP BY c.id, c.name
HAVING total_spent > 10000
ORDER BY total_spent DESC;Initial execution time: 45 seconds. Indexes exist on orders.customer_id, orders.order_date, and order_items.order_id. The EXPLAIN ANALYZE shows a sequential scan on order_items because the join to orders does not filter enough rows—the optimizer estimates it will scan most of the table.
Step 1: Filter Early
The query joins all three tables before filtering by date. We can rewrite to filter orders first:
WITH filtered_orders AS (
SELECT id, customer_id FROM orders
WHERE order_date >= '2024-01-01' AND order_date < '2024-07-01'
)
SELECT c.name, SUM(oi.quantity * oi.price) as total_spent
FROM customers c
JOIN filtered_orders fo ON c.id = fo.customer_id
JOIN order_items oi ON fo.id = oi.order_id
GROUP BY c.id, c.name
HAVING total_spent > 10000
ORDER BY total_spent DESC;Now the date filter applies to orders before the join to order_items. Execution time drops to 22 seconds. The plan now uses an index on orders.order_date and then a nested loop join to order_items using the index on order_id.
Step 2: Reduce Aggregation Work
The HAVING clause filters after aggregation. We can move the filter inside the CTE to reduce the number of rows aggregated:
WITH filtered_orders AS (
SELECT id, customer_id FROM orders WHERE order_date >= '2024-01-01' AND order_date < '2024-07-01'
),
customer_totals AS (
SELECT c.id, c.name, SUM(oi.quantity * oi.price) as total_spent
FROM customers c
JOIN filtered_orders fo ON c.id = fo.customer_id
JOIN order_items oi ON fo.id = oi.order_id
GROUP BY c.id, c.name
)
SELECT * FROM customer_totals WHERE total_spent > 10000 ORDER BY total_spent DESC;This does not change the plan significantly because HAVING is applied after aggregation anyway. But it makes the intent clearer.
Step 3: Consider a Materialized View
If this query runs daily, we can create a materialized view that pre-aggregates the data:
CREATE MATERIALIZED VIEW daily_customer_spending AS
SELECT c.id, c.name, o.order_date, SUM(oi.quantity * oi.price) as daily_total
FROM customers c
JOIN orders o ON c.id = o.customer_id
JOIN order_items oi ON o.id = oi.order_id
GROUP BY c.id, c.name, o.order_date;Then the report query becomes:
SELECT name, SUM(daily_total) as total_spent
FROM daily_customer_spending
WHERE order_date >= '2024-01-01' AND order_date < '2024-07-01'
GROUP BY id, name
HAVING SUM(daily_total) > 10000 ORDER BY total_spent DESC;This runs in under 2 seconds. The trade-off is that the materialized view must be refreshed, which adds write overhead and staleness. For a daily report, refreshing once per day is acceptable.
Edge Cases and Exceptions
Advanced optimization techniques do not always work as expected. Some edge cases require careful handling.
Parameterized Queries and Plan Caching
When queries use parameters, the optimizer may generate a plan based on the first parameter value, which can be suboptimal for subsequent values. This is known as parameter sniffing. For example, a query that filters on a date column may use an index scan when the first parameter is a recent date (returning few rows) but then reuse that plan for a wide date range, causing a scan of many rows. Solutions include using OPTION (RECOMPILE) (SQL Server), EXECUTE ... USING with dynamic SQL, or adding query hints to force a different plan. However, recompiling every time adds CPU overhead.
Data Skew and Histograms
Databases store statistics in histograms to estimate row counts. If the data is skewed—for example, 90% of orders belong to 10% of customers—the optimizer may underestimate or overestimate the number of rows for a given filter. Updating statistics with a larger sample size can help, but sometimes the only fix is to rewrite the query to avoid the problematic predicate or use a hint to force an index.
Non-Selective Filters
Filters that match a large percentage of rows (e.g., status = 'active' when 80% of rows are active) often lead to full table scans. In such cases, an index on status may not help because the database still needs to read most pages. A covering index that includes all columns needed by the query can avoid table lookups, but that increases index size. Alternatively, consider partitioning the table by status if the data is static enough.
Another edge case is when the query uses OR conditions across different columns. The optimizer may struggle to combine indexes. A union of separate queries, each using a single index, can be faster:
SELECT * FROM orders WHERE status = 'pending' OR priority = 'high'
-- rewrite to
SELECT * FROM orders WHERE status = 'pending'
UNION ALL
SELECT * FROM orders WHERE priority = 'high' AND status != 'pending';This allows the database to use an index on status for the first part and an index on priority for the second, then combine results.
Limits of the Approach
Even with advanced techniques, there are scenarios where query optimization cannot overcome fundamental constraints.
Hardware and Configuration Bottlenecks
If the database server has insufficient memory, slow disks, or a misconfigured buffer pool, no query rewrite will fully compensate. For instance, a query that requires sorting 100 GB of data will be slow on a server with 4 GB of RAM, regardless of how well the query is written. In such cases, scaling up hardware or tuning the database configuration (e.g., increasing work_mem in PostgreSQL) is necessary.
Inherently Complex Queries
Some business problems require complex queries with many joins and aggregations. No amount of optimization can make a query that processes 100 million rows run in milliseconds. The solution may involve denormalization, pre-aggregation, or changing the data model. For example, a real-time dashboard that needs sub-second response times might need to use a columnar store or a caching layer like Redis instead of relying solely on query optimization.
Maintenance Overhead
Materialized views, query hints, and plan guides add maintenance burden. When the schema changes, these artifacts may break or become suboptimal. Teams must document why each optimization was added and test it after schema changes. In fast-moving projects, the cost of maintaining such optimizations can outweigh the performance benefits.
Another limit is that optimization is often specific to the dataset size and distribution. A query that runs well on a development database with 10k rows may perform poorly in production with 10 million rows. Always test with production-scale data.
Reader FAQ
When should I use a materialized view instead of a regular view?
Materialized views are best for queries that are run frequently and where the underlying data changes relatively slowly. They precompute and store the result, so reads are fast, but writes become slower because the materialized view must be refreshed. Use them for dashboards, reports, or any query where staleness of seconds or minutes is acceptable. For real-time data, stick with regular views or live queries.
How do I decide between rewriting a query and adding an index?
Start with execution plan analysis. If the plan shows a full table scan on a large table and the query filters on a column with high selectivity (few rows match), an index is likely the best fix. If the plan shows inefficient join order or excessive data processing (e.g., scanning many rows that are later filtered), query rewriting is more appropriate. Often, a combination works best: rewrite the query to reduce data volume, then add an index on the remaining critical columns.
Are query hints safe to use in production?
Query hints (like FORCE INDEX in MySQL or OPTION (RECOMPILE) in SQL Server) can improve performance in specific cases, but they come with risks. They override the optimizer's decisions, which may become suboptimal as data changes. Use hints as a last resort, and document why they were added. Test them after every major data load or schema change. In general, prefer statistics updates and query rewrites over hints.
What is the role of database statistics in query optimization?
Statistics help the optimizer estimate the number of rows that a filter or join will return. Accurate statistics lead to better plan choices. Outdated statistics can cause the optimizer to choose a bad plan. Regularly update statistics, especially after large data changes (bulk inserts, deletes, or updates). In PostgreSQL, ANALYZE updates statistics; in SQL Server, the sp_updatestats procedure is common.
How do I optimize queries that run in a transaction with many writes?
Queries that run inside a transaction that also includes many writes can suffer from lock contention and bloated transaction logs. Try to separate read-heavy queries from write-heavy transactions. If that is not possible, consider using snapshot isolation or read-committed snapshot in SQL Server to avoid blocking. Also, ensure that indexes on tables being written to are not overly numerous, as each write must update the indexes.
These techniques form a toolkit for moving beyond indexing. The key is to adopt a systematic workflow: identify the bottleneck via execution plans, apply targeted rewrites or structural changes, measure the impact, and iterate. No single technique works for every query, but understanding the trade-offs gives you the flexibility to choose the right tool for the job.
Comments (0)
Please sign in to post a comment.
Don't have an account? Create one
No comments yet. Be the first to comment!