A query that works fine on a small table can become slow when the table grows to millions of rows.
This is where SQL performance tuning becomes important. The right index can reduce the amount of
data the database needs to read, a better filter can avoid unnecessary rows, and a well-designed
join can make a big difference to execution time. This guide covers the practical techniques used
to improve SQL query performance, including indexing, query rewriting, execution plans, partition
pruning, denormalisation, avoiding unnecessary correlated subqueries, and choosing appropriate
data types. The examples use the same customer and order tables as the rest of the series, followed
by twenty-five questions commonly asked in SQL interviews.
🎯 Key Takeaways
Index the columns you filter, join, and sort on
EXPLAIN shows where the time actually goes
Avoid SELECT * — read only what you need
Filter early with WHERE, before joins and grouping
Partition large tables by a range column
25 Interview QuestionsBeginner to AdvancedReal ExamplesUpdated Oct 7, 2026
KW
Kailas Warade
✓ Verified Author
Data Engineer · Oracle Certified · SQL & PL/SQL
Kailas works with SQL, PL/SQL, Python, and PySpark to build data pipelines.
He writes the SQL interview guides at Sankalan Data Tech, focused on
practical, real-world answers.
🏆 Oracle Certified💼 Data Engineer📝 SQL & PL/SQL Guides📍 Pune, India
Performance optimisation means improving SQL so that queries run faster and use database
resources more efficiently. The important part is not simply changing the query and hoping
it becomes faster. Good performance tuning starts with measuring the problem, identifying
what is causing the slowdown, making a targeted change, and then checking whether the change
actually improved the query.
The need for optimisation becomes more obvious as data grows. A query that runs in a few
milliseconds on a table with a few hundred rows may behave very differently when the same
table contains millions or hundreds of millions of rows. A full table scan, an inefficient
join, or a poorly chosen execution plan can become expensive at that scale. The goal of
optimisation is to reduce unnecessary data access and processing while keeping the query
result correct.
Many performance improvements come from a few common areas. An appropriate index can help
the database find rows more efficiently. Rewriting a query can reduce unnecessary work,
filter data earlier, or replace an inefficient approach with a better one. Schema design
can also affect performance through techniques such as partitioning, choosing appropriate
data types, or denormalising data for reporting workloads. In some cases, updating database
statistics or using optimizer hints may help when the optimizer chooses an unsuitable
execution plan.
There is no single optimisation technique that works for every query. The right approach
depends on the tables, data volume, indexes, query pattern, database engine, and execution
plan. The key is to measure first, understand the actual bottleneck, and then apply the
change that addresses that specific problem.
The Sample Data We Use in Every Example
Same customers and orders tables as the rest of the series. All performance examples
target these two tables, so you can compare the query plans side by side and see the
difference each technique makes.
Three things about this data make the performance examples real:
customer_id is the join column between the two tables — the classic case for indexing.
order_status has low cardinality (three distinct values), the classic case where an index might not help.
Customer C001 has three orders, the others have fewer — real distribution for testing running totals and partitions.
Where to Start — A Method
Optimisation without a method is guesswork. You change something, run the query, and hope
the timing improves. Sometimes it does. Often it does not, and you have wasted an hour
and possibly made the query slower.
Step 1 — find the slow query
You cannot optimise what you have not measured. Most databases track which queries run
and how long they take. PostgreSQL has pg_stat_statements, MySQL has the
slow query log, Oracle has V$SQL and AWR reports, SQL Server has the query
store.
-- PostgreSQL: find the top 10 slowest queries by total time
SELECT query, calls, total_time, mean_time
FROM pg_stat_statements
ORDER BY total_time DESC
LIMIT 10;
Focus on the queries that run the most, or that take the most total time. A query that
runs a thousand times per hour and takes 100 milliseconds each costs more aggregate time
than a query that runs once a day and takes a second.
Step 2 — read the execution plan
-- PostgreSQL
EXPLAIN ANALYZE SELECT ...;
-- MySQL
EXPLAIN SELECT ...;
-- Oracle
EXPLAIN PLAN FOR SELECT ...;
SELECT * FROM TABLE(DBMS_XPLAN.DISPLAY);
The plan tells you which tables were scanned, which indexes were used, how many rows
each step processed, and where the time actually went. It is the single most important
tool in SQL performance work.
Step 3 — identify the bottleneck
Three symptoms cover most slow queries:
Sequential scan on a large table. The database is reading every row. An index is probably missing, or the optimiser decided the index was not worth using.
Row count explosion at a join or filter step. The optimiser's estimate was wrong. Statistics are stale or the query is written in a way that hides information from it.
Sort on a huge result set. The query is sorting millions of rows in memory or spilling to disk. An index on the sort column can eliminate the sort entirely.
Step 4 — make one change and measure
Add the index. Rewrite the filter. Refresh the statistics. Then run the query again with
EXPLAIN ANALYZE and compare. Only one change at a time — otherwise you
cannot tell which one helped.
Step 5 — verify under load
A query that runs in 50 milliseconds on a quiet system might run in 500 under load. Run
your test with production-like data volume and concurrent traffic, otherwise you will
optimise the wrong thing.
Indexing for Performance
Indexes are the biggest single lever you have. A query that used to scan a million rows
and take three seconds becomes one that reads three rows in three milliseconds. Nothing
else in the toolkit comes close.
The basic win
-- Before: full table scan
SELECT customer_name, credit_limit
FROM customers
WHERE region = 'North';
-- Add an index
CREATE INDEX idx_customers_region ON customers (region);
-- After: index lookup — order of magnitude faster on large tables
On the small sample table, the difference is invisible. On a million-row customers
table, the scan reads every row and the index lookup reads the ones that match. That
is the whole point.
Index the right columns
WHERE clauses. A filter that scans is exactly what an index prevents.
JOIN conditions. Every foreign key column on the "many" side of a one-to-many relationship.
ORDER BY and GROUP BY. An index provides pre-sorted data and lets the database skip the sort step.
High-cardinality columns. A column with many distinct values filters out more rows per lookup.
Composite indexes for common filter combinations
CREATE INDEX idx_orders_customer_status
ON orders (customer_id, order_status);
That index helps a query that filters on both columns together. It also helps a query
that filters on customer_id alone. It does not help a query that filters
only on order_status, because the index is sorted by customer first.
Covering indexes
CREATE INDEX idx_orders_covering
ON orders (customer_id, order_id, order_amount);
A covering index contains every column the query needs. The database answers the query
from the index alone, without touching the table. That is the fastest kind of lookup
available.
The cost of indexing
Every index slows down writes because it has to be maintained. Three indexes on a table
means every INSERT is four writes. On write-heavy tables, this is a real cost that has
to be weighed against the read benefit. Index what queries actually need, and drop
indexes that are not being used.
Avoiding SELECT *
SELECT * is convenient to write and expensive to run. It reads every column
from the table, even the ones the query does not need. On a table with thirty columns,
that is twenty-eight columns of wasted I/O and network transfer.
The direct cost
-- Inefficient: reads every column
SELECT * FROM customers WHERE customer_id = 'C001';
-- Efficient: reads only what is needed
SELECT customer_id, customer_name, region, credit_limit
FROM customers
WHERE customer_id = 'C001';
On this small table, no difference. On a table with a wide text column or a BLOB, the
gap is enormous — reading a hundred KB of unused data per row when you only wanted a
few integers.
The bigger cost — no covering indexes
The main reason to avoid SELECT * is that it prevents the database from
using a covering index. A covering index is useful only when it contains every column
the query needs. If the query asks for all columns, the index almost never covers them
all, and the database has to visit the table anyway.
CREATE INDEX idx_customers_name_region
ON customers (customer_name, region);
-- This query can be answered from the index alone
SELECT customer_name, region FROM customers WHERE customer_name = 'Alice Corp';
-- This query cannot, because SELECT * asks for columns not in the index
SELECT * FROM customers WHERE customer_name = 'Alice Corp';
The stability cost
A query that lists columns explicitly keeps working when someone adds a new column to
the table. A query that used SELECT * starts returning a different shape of
data, which can break downstream code that assumed a fixed set of columns. I have seen
this take down a nightly pipeline more than once — a column was added, and a JSON
exporter started emitting it without the receiving system being ready for it.
The habit: list the columns. It is thirty seconds of typing, and it pays off every time
the schema changes.
Reading Execution Plans
The query optimiser picks a plan every time you run a query. EXPLAIN shows
you the plan it picked. That is the entire diagnostic workflow — read what the database
says it is going to do, and fix the parts that do not look right.
The basic command
-- PostgreSQL
EXPLAIN ANALYZE
SELECT c.customer_name, o.order_amount
FROM customers c
JOIN orders o ON c.customer_id = o.customer_id
WHERE c.region = 'North';
EXPLAIN ANALYZE runs the query and reports the actual timings for each
step. Plain EXPLAIN shows the estimated plan without running it. Use ANALYZE
when you want real numbers; use plain EXPLAIN when you do not want to touch the data.
What to look for
Seq Scan or Full Table Scan on a large table — the database is reading every row. An index is probably missing.
Index Scan or Index Seek — the database is using an index. Good sign, but check the number of rows returned.
Nested Loop with a high outer row count — the database is running the inner side once per outer row. Usually a sign of an underestimation.
Sort on a large result set — the database is sorting in memory or spilling to disk. A pre-sorted index would let it skip this step.
Hash Join or Merge Join — usually fine on large tables where neither side is small.
The optimiser estimated a thousand rows but got seven. That mismatch means its statistics
are wrong — the table has changed since the last statistics refresh. The bad estimate
may lead to a bad plan. Refreshing statistics usually fixes it.
Read plans bottom-up. The deepest nodes run first.
Compare row estimates to actuals. Mismatches point to stale statistics or miscalculated filters.
Look at the number of rows each step passes to the next. If a step produces more rows than expected, the rest of the plan carries that cost.
Use EXPLAIN (ANALYZE, BUFFERS) in PostgreSQL to see how much I/O each step causes.
WHERE vs HAVING for Performance
WHERE filters rows before grouping. HAVING filters groups
after. Both can filter, but they run at different stages, and that difference has a
performance consequence.
The execution difference
-- WHERE runs before GROUP BY — filters rows early
SELECT region, COUNT(*) AS customers
FROM customers
WHERE credit_limit IS NOT NULL
GROUP BY region;
-- HAVING runs after GROUP BY — filters groups late
SELECT region, COUNT(*) AS customers
FROM customers
GROUP BY region
HAVING COUNT(*) > 1;
The WHERE version removes NULL rows before grouping. That means fewer rows feed into the
GROUP BY, so the aggregation has less work to do. The HAVING version does the full
aggregation first and filters afterwards. On large tables, that is a real difference.
The rule
Any condition that does not depend on an aggregate belongs in WHERE. Only
conditions that reference SUM, COUNT, AVG,
MIN, MAX, or another aggregate go in HAVING.
The common mistake
-- Less efficient: filters after grouping
SELECT region, COUNT(*) AS customers
FROM customers
GROUP BY region
HAVING region IS NOT NULL;
-- More efficient: filters before grouping
SELECT region, COUNT(*) AS customers
FROM customers
WHERE region IS NOT NULL
GROUP BY region;
Both return the same output on our data. The second is faster because the filter runs
earlier. The column is not an aggregate, so WHERE can do the job.
Combining both
SELECT region, COUNT(*) AS customers
FROM customers
WHERE credit_limit IS NOT NULL
GROUP BY region
HAVING COUNT(*) > 1;
Here WHERE removes rows with NULL credit limits (a row-level filter), and HAVING removes
regions with only one customer (a group-level filter). Both run at their appropriate
stage. This is the correct pattern when both types of filter apply.
Correlated Subqueries and Their Alternatives
A correlated subquery references a column from the outer query. It cannot run
standalone, and the database evaluates it once per outer row. On a table with a million
rows, that is a million subquery executions — even if each one is fast, the total adds
up.
The pattern that gets slow
SELECT c.customer_name,
c.credit_limit
FROM customers c
WHERE c.credit_limit > (
SELECT AVG(o.order_amount)
FROM orders o
WHERE o.customer_id = c.customer_id
);
For each customer, the inner query computes the average of that customer's orders. The
outer query compares each customer's credit limit against that average. On seven rows,
the cost is invisible. On a million customers, it is a million subquery executions.
The rewrite — join against a grouped subquery
SELECT c.customer_name,
c.credit_limit,
t.avg_order
FROM customers c
LEFT JOIN (
SELECT customer_id,
AVG(order_amount) AS avg_order
FROM orders
GROUP BY customer_id
) t ON c.customer_id = t.customer_id
WHERE c.credit_limit > t.avg_order;
Same output, but the aggregation runs once for the whole orders table instead of once
per customer. On large tables, the difference can be minutes versus seconds.
Two different patterns — know which to reach for
Correlated subquery that runs once per row. Like the example above.
Almost always slower than a join.
Uncorrelated scalar subquery. Runs once and produces a value used for
every outer row. Something like
WHERE credit_limit > (SELECT AVG(credit_limit) FROM customers). That one
is fine — the subquery runs once and the comparison happens per row.
The question to ask yourself: does the subquery reference any column from the outer
query? If yes, it is correlated and probably slow. If no, it is scalar and probably fine.
EXISTS as an alternative
-- Correlated EXISTS — checks whether any matching row exists
SELECT c.customer_name
FROM customers c
WHERE EXISTS (
SELECT 1 FROM orders o WHERE o.customer_id = c.customer_id
);
That is technically a correlated subquery, but it is efficient because it short-circuits.
The moment it finds one matching order for a customer, it stops looking. For a customer
with a million orders, it reads one row and returns. That is why EXISTS is preferred over
IN for existence checks on large tables.
Data Types and Performance
Data types decide how much space a value takes and how quickly the database can compare
it. On small tables the difference is invisible. On tables with hundreds of millions of
rows, it compounds into gigabytes of storage and millions of extra page reads.
The narrow-type advantage
-- Wide: VARCHAR for an ID that is always numeric
CREATE TABLE users (
user_id VARCHAR(10),
name VARCHAR(100)
);
-- Narrow: INT for the same ID
CREATE TABLE users (
user_id INT,
name VARCHAR(100)
);
An INT takes four bytes. A VARCHAR(10) holding the same digits takes at least ten bytes
plus a length prefix. On a table with a billion rows, that difference is six gigabytes
of storage and every index built on the column is correspondingly larger.
Where the difference shows up
Storage. Narrower columns mean smaller tables and smaller indexes. Less disk, less memory used by the buffer cache.
Index size. Smaller indexes fit in memory better, which means faster lookups.
Comparison speed. Integers compare faster than strings. On a sort of a million rows, the difference adds up.
Join speed. A join between two INT columns is faster than a join between two VARCHAR columns.
Choosing types
Numeric IDs. Use INT or BIGINT. Not VARCHAR.
Money. Use DECIMAL or NUMERIC. Not FLOAT — floating-point rounding errors are a real problem for financial data.
Short text. VARCHAR with a sensible length limit. Not VARCHAR(255) as a default — that is often the maximum length, not the actual one.
Dates and times. Use DATE or TIMESTAMP. Not VARCHAR formatted as a date, which breaks every date comparison.
Booleans and small enumerations. Consider TINYINT or a small numeric. On some databases BOOLEAN takes one byte.
The VARCHAR(MAX) trap
Using very large VARCHAR limits "just in case" prevents the database from storing the
column inline. Some databases move it out-of-row, which means an extra read every time
you fetch it. Set the limit to what you actually need and enforce it with a CHECK
constraint or application validation. It is a small design choice with an outsized
impact on storage and query speed.
Partitioning Large Tables
Partitioning splits one logical table into many physical pieces, called partitions,
based on a column value. Usually that column is a date. When a query filters on the
partition column, the database reads only the relevant partitions instead of the whole
table. That is called partition pruning.
Range partitioning by date
CREATE TABLE orders_partitioned (
order_id INT,
order_date DATE,
customer_id VARCHAR(10),
total_amount DECIMAL(12,2)
)
PARTITION BY RANGE (order_date) (
PARTITION p_2023 VALUES LESS THAN ('2024-01-01'),
PARTITION p_2024 VALUES LESS THAN ('2025-01-01'),
PARTITION p_2025 VALUES LESS THAN ('2026-01-01')
);
Each partition is stored as its own physical structure. A query for WHERE order_date BETWEEN '2024-01-01' AND '2024-12-31'
only reads p_2024. The other partitions are not touched. On a table with
three years of data, that is one-third of the I/O.
Why it helps
Partition pruning. Queries that filter on the partition key read less data.
Maintenance windows. Old data can be dropped by dropping a whole partition — instant instead of a slow DELETE.
Parallel operations. Different partitions can be scanned in parallel.
Index locality. Indexes on a partitioned table are partitioned too, so they are smaller and faster.
When NOT to partition
Small tables. Partitioning has overhead. Below a few million rows, it does not pay off.
Tables where queries never filter on the partition column. If every query scans the whole table anyway, partitioning does nothing.
Tables with constantly-changing partition keys. Moving a row between partitions is expensive.
Common partition strategies
Range. By date, most commonly. Each partition holds one month or one year.
List. By a discrete value, like region or country.
Hash. By the hash of a column, used when no natural range applies. Ensures even distribution.
Partitioning is a storage-layer decision, not a query rewrite. The same SQL runs against
the partitioned table as the unpartitioned one. The database handles the partition
selection automatically. When partitioning works, you can usually see it in the
execution plan as a "partitions scanned" count that is less than the total.
Denormalisation for Read Performance
Normalisation removes redundancy — each fact is stored once. That makes writes clean and
consistent. It also means every read has to join tables back together. Denormalisation
reverses that trade-off: store some redundant data so reads do not have to join.
The problem normalisation creates
-- Normalised: three tables, two joins
SELECT c.customer_name,
o.order_id,
p.product_name,
oi.quantity
FROM customers c
JOIN orders o ON c.customer_id = o.customer_id
JOIN order_items oi ON o.order_id = oi.order_id
JOIN products p ON oi.product_id = p.product_id
WHERE c.region = 'North';
That query joins four tables. On a small dataset it runs instantly. On a warehouse with
hundreds of millions of rows, every join multiplies the work — the optimiser has to
choose an order, and each intermediate result has to be materialised.
The denormalised alternative
-- Denormalised: one table with the columns already combined
SELECT customer_name,
order_id,
product_name,
quantity
FROM order_details_wide
WHERE region = 'North';
Same output, one table, no joins. On a large warehouse, that can turn a query that takes
ten seconds into one that takes two hundred milliseconds.
The trade-off
Storage grows. The same customer name is stored on every order row for that customer.
Updates are harder. If a customer changes their name, every denormalised row has to be updated.
Consistency is at risk. If the denormalised copy goes stale, the report shows the wrong data.
Where denormalisation is used
Data warehouses. Star schemas and fact tables are denormalised by design.
Reporting databases. A read replica or a materialised view dedicated to reports.
Materialised views. The database stores the join result and refreshes on schedule.
Cache tables. A batch job periodically writes the joined result into a wide table.
The right place for denormalisation
Denormalisation belongs in the read layer, not the write layer. Keep the transactional
tables normalised — that is where consistency matters most. Then build a denormalised
copy for reporting, refreshed on a schedule. Users get fast reports, and the source of
truth stays clean.
Query Hints and When to Use Them
A query hint is an instruction to the optimiser that overrides its normal choice. Use
this index, use that join algorithm, don't parallelise. Hints are a way to force a
specific plan when you know better than the optimiser.
The syntax
-- Oracle: force an index
SELECT /*+ INDEX(customers idx_customers_region) */
customer_name
FROM customers
WHERE region = 'North';
-- SQL Server: force an index
SELECT customer_name
FROM customers WITH (INDEX(idx_customers_region))
WHERE region = 'North';
-- MySQL: force or ignore an index
SELECT customer_name
FROM customers FORCE INDEX (idx_customers_region)
WHERE region = 'North';
Each database has its own hint syntax. Oracle uses /*+ ... */ comments.
SQL Server uses WITH (INDEX(...)). MySQL uses USE INDEX,
FORCE INDEX, or IGNORE INDEX. PostgreSQL deliberately does
not support hints — the design philosophy is that the optimiser should be trusted.
The case for hints
The optimiser is picking a wrong plan and you have confirmed via EXPLAIN.
You have tested both plans and the hinted one is measurably faster.
The workload is stable and unlikely to change in ways that would make the hint obsolete.
The case against hints
The optimiser is usually right. Forcing a plan usually makes things worse.
A hint that helps today can hurt in three months when the data distribution changes.
Hints lock in decisions that should adapt. They become technical debt.
Most of the time a missing index or stale statistics is the real problem, not the optimiser's choice.
The rule I follow
A hint is a last resort. Before using one, confirm three things: statistics are fresh,
indexes are in place, and the query is rewritten as cleanly as possible. If a hint still
helps, use it, document why, and revisit the decision when the underlying data changes.
Reaching for a hint first is a signal of not having checked the basics. Reaching for a
hint after everything else has been tried is a sign of real diagnosis.
Statistics and the Optimiser
The optimiser picks a plan by estimating the cost of each candidate. Those estimates
come from statistics — a picture of what is in the data. If the picture is wrong, the
estimates are wrong, and the plan is bad.
With that information, the optimiser knows that WHERE region = 'North' will
return about 3 of 7 rows — a large fraction. It might choose a full scan. For
WHERE customer_id = 'C001', the optimiser knows only one row matches, and
it will pick the index.
When statistics go stale
Statistics refresh on a schedule, and the schedule varies by database. If the schedule
is too slow — say a nightly refresh on a table that receives a million rows per hour —
the statistics are hours behind reality. That is when the optimiser picks bad plans.
After a large bulk load — millions of new rows invalidate the old distribution.
After creating a new index on an existing table.
When a query's plan changes for no obvious reason.
When a query is much slower than its shape suggests.
After a significant data skew appears — say a new customer that suddenly accounts for half the orders.
Histograms for skewed data
Default statistics track the number of distinct values and a rough distribution. On data
with severe skew — say one product that accounts for 90% of orders — a plain distribution
does not capture the imbalance. Histograms do. Most databases support them, and they
are worth turning on for columns where the distribution is very uneven.
How to check
Every database exposes its statistics in system views. When a query's plan looks wrong,
checking whether the estimates are close to the actuals is the fastest diagnostic. A big
mismatch means the statistics are stale.
Batch Operations and Bulk Loading
Individual row operations are expensive. Each one has overhead — parsing, planning,
logging, committing. On a bulk load of a million rows, running a million single-row
inserts is a recipe for a very slow job.
The difference between single-row and bulk
-- Slow: one row at a time
INSERT INTO customers (customer_id, customer_name, region)
VALUES ('C008', 'Henry Co', 'East');
INSERT INTO customers (customer_id, customer_name, region)
VALUES ('C009', 'Ivan Trading', 'East');
-- ... a million more times
-- Fast: batch the rows
INSERT INTO customers (customer_id, customer_name, region)
VALUES
('C008', 'Henry Co', 'East'),
('C009', 'Ivan Trading', 'East'),
('C010', 'Julia Wholesale', 'West'),
-- ... hundreds at a time
;
The batch version sends fewer commands to the database, amortising the parsing and
planning cost across many rows. On real workloads, batch inserts regularly run ten to a
hundred times faster than single-row loops.
Bulk loading tools
Every database has a bulk loading tool designed for maximum throughput. These bypass
most of the per-row overhead and can load millions of rows in seconds.
PostgreSQL:COPY command.
MySQL:LOAD DATA INFILE.
Oracle: SQL*Loader, external tables.
SQL Server: BULK INSERT, bcp utility.
Batch sizes
Batch too small and you lose the benefit. Batch too large and you hold a long transaction,
lock too much, and risk running out of undo space. The sweet spot is usually a few
thousand rows per batch — small enough to keep the transaction manageable, large enough
to amortise the overhead. Somewhere between 500 and 5000 rows is a typical starting
point.
Disabling indexes during bulk load
On very large loads, dropping the indexes, loading the data, and rebuilding the indexes
is faster than loading with the indexes in place. That works because the index build
reads the sorted data once, rather than being updated per row during the load.
-- Disable indexes
ALTER INDEX idx_customers_region UNUSABLE;
-- Bulk load
COPY customers FROM '/data/customers.csv' CSV;
-- Rebuild indexes
ALTER INDEX idx_customers_region REBUILD;
That works best when the load is a one-time event or a nightly job where a few minutes
of index downtime is acceptable. On a live table, the index has to stay in place.
25 Interview Questions and Answers
These are the performance optimisation questions that come up most often. Every answer
stands on its own — read them in any order, or work through them in sequence to build up
the full picture.
/
🔍
No matching questions found
Try a keyword like index, EXPLAIN, or partition.
Performance optimisation is the practice of making SQL queries run faster and use
less resource. It covers indexing, query rewriting, execution plan analysis,
partitioning, and any other technique that reduces the time or cost of a query.
The reason it matters is scale. On a table with a few hundred rows, any query is
fast regardless of how it is written. On a table with hundreds of millions of
rows, a poorly-written query takes minutes, and the same query with the right
index takes milliseconds. The gap between optimised and unoptimised grows with
the data volume.
-- Inefficient: reads every column and every row
SELECT * FROM employees;
-- Efficient: reads only what the query actually needs
SELECT employee_id, name, department FROM employees;
That example is a small one, but it is the shape of every improvement. Read less,
do less, and the query gets faster. The four techniques that drive most of the
wins are indexing, query rewriting, schema design, and statistics maintenance.
An index is a data structure that lets the database find rows without scanning
the whole table. When a query filters, joins, or sorts on an indexed column, the
database reads the index instead of reading every row.
-- Without an index: full table scan
SELECT customer_name FROM customers WHERE customer_id = 'C001';
-- Create an index on the column
CREATE INDEX idx_customers_id ON customers (customer_id);
-- Now the query finds the row via the index
On our small table, the difference is invisible. On a table with a million rows,
the scan reads every row and the index lookup reads the ones that match. The gap
widens with the table size, which is why indexes matter most on large data.
Indexes matter most for WHERE clauses, join conditions, and ORDER BY / GROUP BY
clauses. They matter least for low-cardinality columns where the filter does not
narrow the result much.
The cost of indexing is on the write side. Every INSERT, UPDATE, or DELETE has to
maintain every index on the table. On a write-heavy table, indexes are pure
overhead unless the reads justify them. On a read-heavy table, indexes are
usually the single biggest performance lever.
SELECT * reads every column, whether you need them or not. That
costs extra I/O, extra network transfer, and extra memory — and it prevents the
database from using a covering index.
-- Inefficient
SELECT * FROM orders WHERE order_id = 101;
-- Efficient
SELECT order_date, total_amount FROM orders WHERE order_id = 101;
On a table with a few columns, the difference is small. On a table with fifty
columns, or a wide text column, or a BLOB, the difference is enormous — you are
reading megabytes of data you never use.
The other problem is stability. A query that lists columns explicitly keeps
working when someone adds a new column to the table. A query that used
SELECT * starts returning a different shape of data, which can break
downstream code that expected a fixed set of columns. I have seen this take down
a nightly pipeline.
The rule: list the columns. It is thirty seconds of typing and it pays off every
time the schema changes.
An execution plan shows how the database plans to run a query — which tables it
will scan, which indexes it will use, in what order it will join them, and how it
will filter and sort. EXPLAIN shows you the plan.
-- PostgreSQL
EXPLAIN ANALYZE
SELECT c.customer_name, o.order_amount
FROM customers c
JOIN orders o ON c.customer_id = o.customer_id
WHERE c.region = 'North';
The output reveals which indexes were used, how many rows each step processed,
and how long each step took. It is the fastest way to find why a query is slow.
What to look for:
Seq Scan / Full Table Scan on a large table — an index is probably missing.
Row estimate vs actual mismatch — statistics are stale.
Nested Loop with high outer rows — the inner side runs too many times.
Sort on a large result — an index could eliminate it.
Reading plans is the skill that separates SQL developers who guess from those who
diagnose. Once you know how to read the plan, every slow query is a series of
"why is this step expensive" questions with answers in the plan itself.
WHERE filters rows before grouping. HAVING filters
groups after grouping. Both can filter, but they run at different stages of the
query, and that difference has a performance cost.
-- WHERE runs first, filters rows before grouping
SELECT region, COUNT(*) AS customers
FROM customers
WHERE credit_limit IS NOT NULL
GROUP BY region;
-- HAVING runs after grouping, filters the groups
SELECT region, COUNT(*) AS customers
FROM customers
GROUP BY region
HAVING COUNT(*) > 1;
WHERE is generally faster because it reduces the number of rows the GROUP BY has
to process. On a table with a million rows, if WHERE cuts that to ten thousand,
the GROUP BY works on ten thousand rows instead of a million.
The rule: any condition that does not depend on an aggregate belongs in WHERE.
Only conditions referencing SUM, COUNT,
AVG, MIN, MAX, or another aggregate go in
HAVING.
-- Wrong: this is a row-level filter, put it in WHERE
SELECT region, COUNT(*) FROM customers
GROUP BY region
HAVING region IS NOT NULL;
-- Right
SELECT region, COUNT(*) FROM customers
WHERE region IS NOT NULL
GROUP BY region;
Both produce the same output. The second is faster because the filter runs at the
row level, before the aggregation.
Denormalisation stores redundant data so queries do not have to join as many
tables. It trades write complexity and storage for read speed.
-- Normalised: multiple tables, multiple joins
SELECT employees.name, departments.name
FROM employees
JOIN departments ON employees.department_id = departments.department_id;
-- Denormalised: one table, no joins
SELECT employee_name, department_name FROM employee_data;
Same output. The denormalised version reads one table and produces the answer
directly. On a data warehouse with hundreds of millions of rows, avoiding a join
can turn a ten-second query into a two-hundred-millisecond one.
The cost is on the write side and in consistency. The same data lives in multiple
places, which means updates have to be applied everywhere. If the copies go out
of sync, reports show the wrong data.
Where denormalisation is used: data warehouses (star schemas and fact tables are
denormalised by design), reporting databases, materialised views, and cache tables
refreshed on a schedule.
The general advice: keep the transactional tables normalised and build a
denormalised copy for reporting. That way the source of truth stays clean and
reports stay fast.
A query hint is an instruction that overrides the optimiser's choice — use this
index, use that join algorithm, disable parallelism. Hints give you control when
the optimiser's default is wrong.
-- Oracle: force an index
SELECT /*+ INDEX(customers idx_customers_region) */
customer_name
FROM customers
WHERE region = 'North';
-- SQL Server: force an index
SELECT customer_name
FROM customers WITH (INDEX(idx_customers_region))
WHERE region = 'North';
Hints are a last resort. The optimiser is usually right, and a hint that helps
today can hurt in three months when the data distribution changes. Use them only
when you have tested both plans and confirmed the hinted one is faster.
Before reaching for a hint, check three things. Are statistics fresh? Are the
right indexes in place? Is the query written as cleanly as possible? If all three
are yes and the plan is still wrong, then a hint is reasonable.
PostgreSQL deliberately does not support hints. The design philosophy is that the
optimiser should be trusted, and bad plans should be fixed by improving
statistics or rewriting the query, not by locking in a decision.
When you do use a hint, document why. Leave a comment explaining what problem it
solved and when it should be revisited. A hint without a note is a future bug.
Partitioning splits a large table into smaller physical pieces called partitions,
usually by date. When a query filters on the partition column, the database reads
only the relevant partitions instead of the whole table. That is partition
pruning.
CREATE TABLE orders_partitioned (
order_id INT,
order_date DATE,
total_amount DECIMAL(12,2)
)
PARTITION BY RANGE (order_date) (
PARTITION p_2023 VALUES LESS THAN ('2024-01-01'),
PARTITION p_2024 VALUES LESS THAN ('2025-01-01'),
PARTITION p_2025 VALUES LESS THAN ('2026-01-01')
);
A query for WHERE order_date BETWEEN '2024-01-01' AND '2024-12-31'
reads only p_2024. The other partitions are not touched. On a table
with three years of data, that is one-third of the I/O.
Partitioning also helps with maintenance. Dropping a year of old data becomes
DROP PARTITION p_2023 — instant instead of a slow DELETE that logs
every row.
When not to partition. Small tables — the overhead is not worth it. Tables where
queries never filter on the partition column — partitioning does nothing.
Tables with constantly-changing partition keys — moving rows between partitions
is expensive.
Common strategies: range partitioning (by date), list partitioning (by discrete
values like region), and hash partitioning (for even distribution when no
natural range applies). The choice depends on how queries filter the data.
A correlated subquery references a column from the outer query, so it cannot run
standalone. The database evaluates it once per outer row. On a table with a
million rows, that is a million subquery executions.
-- Slow: correlated subquery, one execution per customer
SELECT name FROM employees
WHERE salary > (SELECT AVG(salary) FROM employees WHERE department_id = employees.department_id);
Each execution may be fast on its own. But a million fast executions are still a
million times something. The total time adds up.
-- Faster: join against a grouped subquery, one execution total
WITH dept_avg AS (
SELECT department_id, AVG(salary) AS avg_sal
FROM employees
GROUP BY department_id
)
SELECT e.name
FROM employees e
JOIN dept_avg d ON e.department_id = d.department_id
WHERE e.salary > d.avg_sal;
Same output. But now the aggregation runs once for the whole table, and the join
picks up the result. On large tables, the difference can be minutes versus
seconds.
Two patterns are worth distinguishing:
Correlated subquery — references outer columns. Runs once per row. Usually slow.
Scalar subquery — no outer references. Runs once and produces one value. Usually fine.
-- Scalar subquery — runs once, fine
SELECT customer_name, credit_limit
FROM customers
WHERE credit_limit > (SELECT AVG(credit_limit) FROM customers);
The question to ask yourself: does the subquery reference any column from the
outer query? If yes, look for a way to rewrite it as a join. If no, it is fine as
is.
One exception: EXISTS is technically correlated but efficient,
because it short-circuits as soon as it finds one matching row. EXISTS
is often faster than an equivalent IN on large tables.
Narrower data types take less storage and compare faster. On small tables the
difference is invisible. On tables with hundreds of millions of rows, it
compounds into gigabytes of storage and millions of extra page reads.
-- Wide: VARCHAR for an ID that is always numeric
CREATE TABLE users (user_id VARCHAR(10), name VARCHAR(100));
-- Narrow: INT for the same ID
CREATE TABLE users (user_id INT, name VARCHAR(100));
An INT takes four bytes. A VARCHAR(10) holding the same digits takes at least ten
bytes plus a length prefix. On a billion rows, that difference is six gigabytes
of storage, and every index built on the column is correspondingly larger.
Smaller indexes fit in memory better, which means faster lookups. Narrower columns
also compare faster — integer comparisons are quicker than string comparisons,
especially for sorting and joining.
Type recommendations:
Numeric IDs: INT or BIGINT. Not VARCHAR.
Money: DECIMAL or NUMERIC. Not FLOAT — floating-point rounding errors are a real problem.
Short text: VARCHAR with a sensible length. Not VARCHAR(255) as a default.
Dates and times: DATE or TIMESTAMP. Not VARCHAR formatted as a date.
Booleans: TINYINT or BOOLEAN depending on the database.
One specific trap: VARCHAR(MAX) or equivalent unlimited-size types. They force
the database to store the column out-of-row, which means an extra read whenever
you fetch it. Set a real limit and enforce it.
You cannot optimise what you have not measured. Most databases track which
queries run and how long they take. Find the offenders, then focus your
attention on them.
-- PostgreSQL: top queries by total time
SELECT query, calls, total_time, mean_time
FROM pg_stat_statements
ORDER BY total_time DESC
LIMIT 10;
-- MySQL: enable slow query log
SET GLOBAL slow_query_log = 'ON';
SET GLOBAL long_query_time = 1;
-- Oracle: check V$SQL
SELECT sql_text, elapsed_time, executions
FROM v$sql
ORDER BY elapsed_time DESC;
Two metrics matter. First, the mean time per execution — how slow is each run.
Second, the total time — how much time across all runs. A query that runs a
thousand times per hour at 100 milliseconds each costs more aggregate time than
one that runs once a day at one second.
Focus on the queries with the highest total time. Fixing one that runs a thousand
times per hour and takes 100 milliseconds is worth far more than fixing one that
runs once a day and takes two seconds.
Some databases also track plan changes over time. When a query's plan suddenly
shifts and the runtime goes up, that is usually a statistics problem or an
environmental change like a new index elsewhere on the table.
Four common causes cover most cases. Data volume grows, statistics go stale,
indexes fragment, and the plan changes for reasons that are not obvious from the
query text.
Data volume. A query that was fast on ten thousand rows becomes
slow on a million. If the query relied on a full scan of a small table, growth
breaks that assumption. Adding an index usually fixes it.
Stale statistics. The optimiser's estimates go wrong after a
bulk load or a large data change. It picks a plan that made sense for the old
data and is wrong for the new. Refreshing statistics often fixes it.
Index fragmentation. As rows are inserted and updated, index
pages fragment. Reads become slower because the database visits more pages than
necessary. Rebuilding the index fixes it.
-- Rebuild fragmented indexes
ALTER INDEX idx_orders_customer_id REBUILD;
A new index changed the plan. Adding an index elsewhere on the
table can change the optimiser's choices for unrelated queries. Usually the new
plan is better, but occasionally it is worse.
The diagnostic: run EXPLAIN and compare the current plan against
what you saw when the query was fast. If it changed, one of the four above is
usually the reason. Check statistics first, then index health, then data volume.
Multi-table queries live and die by indexing and join order. Four habits cover
most of what matters.
Index every join column. Every foreign key column on the "many"
side of a one-to-many relationship. A join without an index on the join column
scans the whole table for every row of the outer query.
CREATE INDEX idx_orders_customer_id ON orders (customer_id);
Filter early. A tight WHERE clause reduces the row count before
any joins. On a table with a million rows, filtering to a thousand before joining
is the difference between a plan that works and one that times out.
-- Better: filter before the join
SELECT c.customer_name, o.order_id
FROM customers c
JOIN orders o ON c.customer_id = o.customer_id
WHERE c.region = 'North'
AND o.order_status = 'Delivered';
Select only the columns you need. Listing columns explicitly
lets the database use narrower indexes and reduces the amount of data moving
through the pipeline.
Reduce the number of tables. If a report only needs three
tables, do not join six. Every additional join multiplies the potential row count
and complicates the plan.
The diagnostic tool is EXPLAIN. Look at the row count at each step
of the plan. If a step that should produce a hundred rows produces a million, the
filter is in the wrong place or the join condition is wrong.
A full table scan reads every row in the table. An index scan uses the index to
find specific rows. Usually the index scan is faster, but not always.
-- Full scan: reads every row
Seq Scan on customers
-- Index scan: reads matching rows via the index
Index Scan using idx_customers_region on customers
A full scan wins when the query returns a large fraction of the table. If you are
reading 90% of the rows, jumping between the index and the table is slower than
just reading the table straight through. The optimiser knows this and often
chooses a scan for low-selectivity filters.
A full scan also wins when the index does not exist. On a table with no index on
the filter column, the only option is scanning.
And a full scan wins on tiny tables. If the table fits on a single page, the
overhead of looking up the index is more than the cost of reading the page.
Where a scan loses: on a large table with a selective filter. Reading a million
rows to find ten is expensive; using an index to find those ten directly is
cheap.
The optimiser decides based on cost estimates. If a scan seems wrong for a query
that should be selective, the fix is usually an index (if it is missing) or
refreshed statistics (if the optimiser's estimate is wrong).
Individual row operations pay a per-statement cost — parsing, planning, logging,
committing. Batch operations amortise that cost across many rows. On a million-row
load, the difference is dramatic.
-- Slow: one row at a time
INSERT INTO customers (customer_id, customer_name) VALUES ('C008', 'Henry Co');
INSERT INTO customers (customer_id, customer_name) VALUES ('C009', 'Ivan Trading');
-- ... a million more statements
-- Fast: batch the rows
INSERT INTO customers (customer_id, customer_name)
VALUES
('C008', 'Henry Co'),
('C009', 'Ivan Trading'),
('C010', 'Julia Wholesale')
-- ... hundreds per batch
;
The batch version sends fewer commands to the database, which means less overhead
per row. On real workloads, batch inserts regularly run ten to a hundred times
faster than equivalent single-row loops.
For very large loads, use the database's bulk loading tool. Every database has
one — PostgreSQL has COPY, MySQL has LOAD DATA INFILE,
Oracle has SQL*Loader, SQL Server has BULK INSERT. These bypass most of the
per-row overhead and can load millions of rows in seconds.
Batch size matters. Too small and the overhead still dominates. Too large and
the transaction holds too many locks and might run out of undo space. A few
thousand rows per batch is a common starting point.
On very large loads, dropping indexes, loading, and rebuilding the indexes is
faster than loading with the indexes in place. The index build reads the sorted
data once instead of being updated per row.
ORDER BY on a large result set means the database has to sort. Sorting
is expensive — it uses memory, can spill to disk, and scales worse than linear.
Three techniques help.
Index the sort column. If the column being sorted has an index,
the database reads the rows in sorted order and skips the sort step entirely.
CREATE INDEX idx_orders_amount ON orders (order_amount DESC);
SELECT order_id, order_amount
FROM orders
ORDER BY order_amount DESC;
-- Uses the index instead of sorting
Reduce the rows before sorting. A tight WHERE clause reduces
what has to be sorted. Sorting ten rows is free; sorting ten million is not.
SELECT order_id, order_amount
FROM orders
WHERE order_status = 'Delivered'
ORDER BY order_amount DESC;
Limit the result. If the report only needs the top ten, add
LIMIT 10. The database can stop after finding ten matching rows in
sorted order instead of sorting the entire set.
SELECT order_id, order_amount
FROM orders
ORDER BY order_amount DESC
LIMIT 10;
The combination that works best: index the sort column, filter to a manageable
row count, and add a limit. That turns the query into an index walk with an
early stop, which is the fastest shape a sorted query can have.
Eight patterns come up again and again. None of them are exotic, and all of them
are easy to avoid once you know them.
SELECT * in production code.
Functions on indexed columns — WHERE UPPER(name) = 'ALICE' prevents the use of an index on name.
Leading wildcards in LIKE — WHERE name LIKE '%smith' cannot use an index.
Correlated subqueries that could be joins.
Implicit type conversion — comparing a VARCHAR column to an INT, which forces a conversion and blocks the index.
NOT IN with subqueries that return NULL — slow and often wrong.
OR conditions on different columns — usually not index-friendly. Consider UNION ALL instead.
Ordering by a column that is not indexed on a large result set.
The unifying theme: anything that prevents the database from using an index, or
that forces it to do more work than necessary. Every anti-pattern above either
makes the plan worse or forces the database to read more data than it needs to.
The fix is almost always the same: rewrite the query so the column is bare, so the
comparison uses the right type, so the subquery becomes a join. Once you learn to
spot these patterns, your queries get faster without any other change.
Caching skips the query entirely and returns a pre-computed result. It is the
fastest optimisation technique available because it does nothing — the answer is
already sitting somewhere.
Several kinds of caching help in different situations.
Buffer cache. The database keeps recently-read pages in memory.
A query that hits the buffer cache avoids disk entirely. Almost every database
has this, and it is why the second run of a query is often faster than the first.
Query result cache. Some databases cache the full result of a
query. If the same query runs again with the same inputs and the underlying data
has not changed, the cached result is returned. MySQL has the query cache (though
it was deprecated), and other databases have their own mechanisms.
Application-level cache. Redis, Memcached, or an in-process cache
stores the result of expensive queries. The application checks the cache before
hitting the database. This is the most common approach in modern applications.
Materialised views. The database stores the result of a slow
query in a physical table and refreshes on a schedule. Reads are fast, but the
data can be stale.
-- Application-level cache pseudocode
result = cache.get('top_customers')
if result is None:
result = db.query('SELECT ... slow query ...')
cache.set('top_customers', result, ttl=300)
return result
The trade-off in every case is freshness versus speed. The faster the cache, the
more likely the data is stale. For dashboards, a few minutes of staleness is
usually fine. For transactional data, it is not — the cache has to be invalidated
on every write.
Sharding splits a table horizontally — different rows live on different servers.
Each server holds a subset of the data and only has to handle queries against its
subset.
Where partitioning is a single-server technique, sharding is a multi-server
technique. Both split data, but sharding distributes it across physical machines,
which means each machine has less data, less work, and can be sized independently.
Common sharding strategies:
Range sharding. Customers with ids A–F on server 1, G–M on server 2, etc. Simple but can create hot spots if the ranges are uneven.
Hash sharding.hash(customer_id) % number_of_servers decides where a row lives. Even distribution, but range queries have to hit every shard.
Directory sharding. A lookup table maps each key to a specific shard. Maximum flexibility, extra lookup cost.
Sharding solves the problem of a table that has outgrown a single server. But it
adds real complexity:
Queries that span shards are slow. A report that needs every customer has to query every shard and combine the results.
Joins across shards are hard. Two tables on different shards cannot be joined efficiently.
Transactions across shards need distributed protocols. Two-phase commit, with the complexities that brings.
Most teams reach for sharding only when a single server can no longer handle the
load. Before that, indexing, partitioning, denormalisation, and caching usually
solve the problem more simply.
An index is a bet — it costs storage and write speed in exchange for read speed.
The bet pays off only when the read workload justifies the cost. Four questions
help decide.
Is the column filtered or joined frequently? If queries filter on
the column often, an index is worth considering. If nobody filters on it, the index
does nothing.
Is the column selective? A column with many distinct values
filters out more rows per lookup. A column with three values — say a status flag
— does not help much. A composite index or a bitmap index might be a better fit
for low-cardinality columns.
How often is the table written to? Every index slows every
INSERT, UPDATE, and DELETE. On a write-heavy table, adding indexes has a real
cost. On a read-heavy table, the read benefit dominates.
Does an existing index already cover the query? A composite
index on (region, credit_limit) already covers queries that filter
on region alone. Adding a single-column index on region
would be redundant.
The test for a new index: create it, run the query with EXPLAIN ANALYZE,
and compare timings against the pre-index plan. If the plan changed for the
better and the query is measurably faster, keep it. If the plan is the same and
the timings are the same, drop it — it is costing you writes for nothing.
Once an index is in production, revisit it periodically. The workload changes, and
indexes that were critical six months ago may not be used today. Databases track
usage statistics — check them and drop the ones that are dead.
Partition pruning is the optimiser's ability to skip partitions that cannot
contain any relevant rows. If a query filters on the partition column with a
range, the database reads only the partitions that overlap the range.
CREATE TABLE orders_partitioned (
order_id INT,
order_date DATE,
total_amount DECIMAL(12,2)
)
PARTITION BY RANGE (order_date) (
PARTITION p_2023 VALUES LESS THAN ('2024-01-01'),
PARTITION p_2024 VALUES LESS THAN ('2025-01-01'),
PARTITION p_2025 VALUES LESS THAN ('2026-01-01')
);
SELECT * FROM orders_partitioned
WHERE order_date BETWEEN '2024-01-01' AND '2024-12-31';
That query reads only p_2024. Partitions p_2023 and p_2025 are
skipped entirely. On a table with three years of data, that is one-third of the
I/O.
You can see pruning in the execution plan. A query against a partitioned table
that reads fewer partitions than exist shows a "partitions scanned" count that
is lower than the total. When the count equals the total, pruning is not
happening — either because the filter is not on the partition column, or because
the filter uses a function that prevents the optimiser from reasoning about
ranges.
-- Pruning works: filter on the partition column with a literal range
WHERE order_date >= '2024-01-01' AND order_date < '2025-01-01'
-- Pruning does NOT work: filter wraps the partition column in a function
WHERE EXTRACT(YEAR FROM order_date) = 2024
The second form prevents pruning because the optimiser cannot map the function
result back to partition ranges. Rewriting the filter to compare the raw column
restores pruning — and often turns a slow query into a fast one, with no other
change.
A query plan can change without any change to the query itself. The optimiser
reacts to changes in data volume, statistics, indexes, and configuration.
Usually the new plan is fine, but sometimes it is much worse.
The symptom is a query that used to run fast and now runs slowly. The query text
has not changed, the schema has not changed, but the runtime has. Something in
the environment changed.
Diagnosing plan changes:
Run EXPLAIN and compare against the previous plan.
Check whether the optimiser is using a different index, or scanning a table instead.
Look at the row estimates — are they wildly off from the actuals?
Check when statistics were last refreshed.
Preventing plan surprises:
Keep statistics fresh, especially after bulk loads.
Rebuild fragmented indexes on a schedule.
Monitor query performance over time so you notice changes quickly.
Test schema changes with the full production dataset, not a small sample.
Some databases expose plan history directly. Oracle's AWR reports track plan
changes. SQL Server's Query Store does the same. PostgreSQL has extensions like
pg_stat_statements that can be joined with plan data. When a plan
changes, these tools help you see when and why.
The most useful habit: keep an eye on the slowest queries over time. A query that
jumps from position ten to position one in the slow query log is a signal that
something changed.
Wrapping an indexed column in a function usually disables the index. The
database can no longer use the sorted index to look up values because the index
stores the raw column, not the function's output.
-- Index on customer_name exists
CREATE INDEX idx_customers_name ON customers (customer_name);
-- This query cannot use the index
SELECT * FROM customers WHERE UPPER(customer_name) = 'ALICE CORP';
-- This query can
SELECT * FROM customers WHERE customer_name = 'Alice Corp';
On a small table the difference is invisible. On a million-row table, the first
query scans everything while the second reads three rows.
Three fixes exist. First, rewrite the query to avoid the function on the column:
-- Instead of UPPER(customer_name) = 'ALICE CORP'
WHERE customer_name = 'Alice Corp'
Second, store the transformed value in a separate column and index that:
ALTER TABLE customers ADD customer_name_upper VARCHAR(100);
CREATE INDEX idx_customers_name_upper ON customers (customer_name_upper);
-- Populate with UPDATE, and maintain on insert/update
Third, use a function-based index — a feature some databases support that indexes
the function's output directly:
-- Oracle, PostgreSQL: index the function result
CREATE INDEX idx_customers_name_upper ON customers (UPPER(customer_name));
With that index, the original query can use it. The index stores the uppercased
value, so the lookup runs against the indexed expression. The cost is the same as
any index — extra storage and slower writes — plus the constraint that the query
has to use the exact same expression to hit the index.
The general advice: if you find yourself writing functions on indexed columns
often, either rewrite the query or add a function-based index. Leaving the
function in place and letting the query scan the table is the worst of both
worlds.
Performance tuning on a live system requires discipline. Every change has the
potential to help or hurt, and the difference is often invisible until production
hits the new path.
The approach I use has four phases.
Phase 1 — observe before changing anything. Run the slow queries,
capture their plans, and record the timings. Without a baseline you cannot tell
whether your change helped.
-- Record the current plan and timing
EXPLAIN ANALYZE SELECT ...;
Phase 2 — make one change. Add one index, or rewrite one query,
or refresh statistics on one table. Not three things at once. Isolating the change
lets you attribute the outcome.
Phase 3 — measure the change. Run the same query under the same
conditions. Compare the new plan and timing against the baseline. If the query got
faster without breaking other queries, the change is a win.
Phase 4 — roll back if needed. If the change made things worse,
roll it back. Dropping an index, restoring the old query text, or refreshing
statistics again are all reversible.
-- Roll back an index change
DROP INDEX idx_customers_region;
Two practical habits make this safer. First, make changes during low-traffic
windows when possible. An index rebuild at 3 AM might take a minute; the same
rebuild at 3 PM might slow production for ten minutes.
Second, watch the whole system, not just the query you are optimising. Adding an
index makes writes slower for everyone. Refreshing statistics can change plans
for other queries. The system as a whole is the unit of measure, not just your
target query.
Almost every optimisation technique that speeds up reads slows down writes. The
trade-off is fundamental, and knowing it helps decide which techniques to apply
where.
Read optimisation costs:
Indexes slow every write because the index has to be maintained.
Denormalisation slows writes because redundant data has to be kept in sync.
Materialised views slow writes to the base tables because the view has to be refreshed.
Caching slows writes because the cache has to be invalidated.
Write optimisation costs:
Fewer indexes speeds writes but slows reads.
Batch operations speed writes but increase the transaction size and lock duration.
Autocommit speeds writes but removes the ability to roll back.
The right choice depends on the read-write ratio of the workload.
Read-heavy workloads — reports, dashboards, analytics — can afford
many indexes, denormalised tables, and materialised views. The write cost is
amortised across many reads.
Write-heavy workloads — logging, event capture, high-throughput
inserts — should minimise indexes and avoid denormalisation. Every write matters
and the read benefit is rare.
Mixed workloads — typical OLTP applications — need to find a
balance. Index on the columns that are actually queried, denormalise only the
hottest read paths, and monitor the ratio over time as the workload evolves.
There is no universally correct answer. The right amount of indexing on a customer
table that is read a thousand times per write is different from the right amount
on a log table that is written a thousand times per read. Knowing the shape of
your workload is what makes the decision tractable.
Mistakes I Keep Seeing in Interviews
These come up repeatedly. None of them are exotic, and every one of them is easy to avoid
once you know it is coming.
Mistake one: guessing at what is slow instead of measuring. Run the slow
query log, read the execution plan, and see where the time actually goes. The bottleneck
is often in a place you did not expect.
Mistake two: adding indexes without checking whether they will be used.
An index on a low-cardinality column, or one that duplicates a composite index, is pure
overhead. Test with EXPLAIN before and after.
Mistake three: wrapping indexed columns in functions and expecting the
index to still work. UPPER(name) = 'X' cannot use an index on
name. Either rewrite the query or use a function-based index.
Mistake four: forgetting that statistics go stale. After a bulk load,
the optimiser's estimates can be wildly off. Refresh statistics before concluding that
the query itself is the problem.
Mistake five: changing three things at once. You added an index,
rewrote the query, and refreshed statistics — but you cannot tell which one helped.
Change one thing, measure, then decide.
Mistake six: optimising the wrong query. A query that runs once a day
and takes ten seconds matters less than one that runs a thousand times per hour and
takes 100 milliseconds. Focus on total time, not mean time.
Follow-Up Questions Interviewers Ask
Getting the first answer right is only half the interview. What usually decides the
outcome is what happens next. Here is what to prepare for on the performance topics.
After "How do you optimise a slow query?" — expect
"What tools do you use to find the slow query in the first place?"
"What does EXPLAIN tell you?"
"What would you check first?"
"How do you verify that your optimisation actually worked?"
After "How do indexes help?" — expect
"What is the trade-off of adding an index?"
"How do you decide which columns to index?"
"What is a covering index and when would you use one?"
"Why might the optimiser choose not to use an index?"
After "What is partitioning?" — expect
"What is partition pruning?"
"When would partitioning not help?"
"How does partitioning differ from sharding?"
"What is the difference between range, list, and hash partitioning?"
After "How do you handle large tables?" — expect
"How do you decide between indexing and partitioning?"
"How does denormalisation fit in?"
"What is the impact of adding a new column to a large table?"
"How do you handle bulk data loads without locking the table?"
For every question above, prepare a second-level answer. The candidates who pass are not
the ones who memorised the first answer — they are the ones who kept going for another
thirty seconds and said something useful about why it matters.
Quick Quiz
Three questions to check whether the topics above have landed.
1. Which tool shows you how the database plans to run a query?
2. Which of these prevents an index from being used on a column?