A query that returns the right answer can still do unnecessary work. It might read a whole table to find a few rows, sort a large intermediate result, or multiply records through an unintended join. SQL optimization means reducing that work while preserving the answer.
Begin by writing down what the query must return, including duplicates, missing values and ordering. A rewrite that is fast because it silently drops unmatched customers is not an improvement. Then measure the database work with an execution plan and realistic data. This guide connects readable filters, appropriate indexes and reliable comparisons rather than presenting a collection of speed tricks without context.
Find the actual bottleneck
Collect the query text, its parameters, table sizes, relevant indexes and observed duration. If possible, inspect an actual execution plan on a representative sandbox. Compare estimated row counts with actual counts and find where the work grows unexpectedly.
A selective lookup needs a different strategy from an export that intentionally reads most of a table. An index can be excellent for one customer and less useful for every customer. Parameter distribution matters too: a common category may match half the table while a rare category matches ten rows.
Separate database execution time from time spent transferring and rendering results. Returning millions of unused columns can make an application slow even when the server's access path is efficient. Select the columns needed by the caller and define a sensible result boundary when the use case permits one.
Record comparable measurements. Cache state, concurrent activity and different parameter values can overwhelm the effect of a small rewrite. A claim about improvement should identify the data size and workload, not just one unusually fast run.
Sargable filters and suitable indexes
A sargable filter is one the optimizer can use effectively as a search argument for an access path. Applying a function to every indexed value can prevent an ordinary index from supplying a direct lookup range. Comparing a timestamp directly with two boundaries often works better than extracting its month and year for every row.
This is not an absolute ban on functions. Some engines support expression indexes or computed-column indexes that match a transformed value. Their syntax and matching rules differ. Use them when the workload justifies the extra structure, and verify the actual plan.
For a query filtering by customer and then date, consider a composite index beginning with customer and continuing with date. For an all-customer date export, a date-first index may be more appropriate. Decide from the query's equality conditions, ranges, joins and required order rather than column popularity alone.
Type conversions can also obstruct access or alter comparisons. Bind values with the intended types and avoid comparing a numeric column to an arbitrary string. Collation, case sensitivity and timezone conversion need equally explicit policies when they affect the search.
Joins, pagination and unnecessary work
Check join cardinality before tuning syntax. If each order joins several order items and several payments, the combined rows can multiply. Aggregating that result may inflate totals as well as increase work. Pre-aggregate each child dataset to the required grain before joining when the question calls for one row per order.
DISTINCT is not a general repair for a bad join. It can hide duplicate-looking output while leaving the underlying relationship wrong. Write the intended keys and expected row counts, then correct the join condition or grain.
For interactive lists, deep offset pagination can require skipping many rows. Keyset pagination uses the last displayed ordering values as the next boundary and can be more efficient for sequential navigation. It needs a stable unique order and careful handling of filters and concurrent updates; it does not directly offer arbitrary page-number jumps.
Avoid assuming a CTE always improves performance or always materializes. Optimizer behavior varies by engine and version. Use CTEs to make the logic understandable, then inspect how that readable query actually executes.
Practice: make one justified improvement
Using a sandbox dataset with enough rows to resemble the intended workload, compare a transformed date filter with the direct October range. Confirm that both return the same orders under the intended date semantics. Then capture their plans and comparable timings.
If the direct range uses the index and reduces rows examined, explain that access improvement. If both use scans because October contains most of the data, accept that the optimizer may be making a sensible choice. The lesson is to test the hypothesis rather than forcing a specific plan name.
Next add an unrelated index on amount. Recheck the date query and the write cost. If that index does not support an important workload, it is not justified merely because the table now has more indexes. Keep a short record connecting each proposed index to queries that benefit.
For production changes, include a rollback plan for the schema change and account for index-build resource use. The PostgreSQL performance guide provides official background for interpreting its optimizer and statistics.
SQL Query Optimization and Index Selection Examples
Each example shows the code, the output it produces and what each part does.
Prefer a direct range on an indexed value
The following table stores ISO date text so the example runs in the course's common database families. In a real application, use the engine's appropriate date or timestamp type and equivalent typed boundaries.
The half-open range includes October and excludes the next month. It also avoids guessing the final possible time on October 31 when the column is a timestamp. A direct comparison can give an ordinary index a useful search boundary.
CREATE TABLE optimized_orders (
order_id INTEGER PRIMARY KEY,
ordered_day VARCHAR(10) NOT NULL,
amount INTEGER NOT NULL
);
INSERT INTO optimized_orders VALUES
(1, '2026-09-30', 10), (2, '2026-10-01', 20),
(3, '2026-10-31', 30), (4, '2026-11-01', 40);
CREATE INDEX idx_optimized_day ON optimized_orders(ordered_day);
SELECT order_id, amount
FROM optimized_orders
WHERE ordered_day >= '2026-10-01'
AND ordered_day < '2026-11-01'
ORDER BY ordered_day, order_id;order_id | amount
2 | 20
3 | 30SQL Query Optimization and Index Selection FAQ
Should every slow query get an index?
No. The issue may be an incorrect join, an oversized result, stale estimates, locks or application work.
Is SELECT * always wrong?
It can be convenient during exploration. A stable application query usually benefits from explicit required columns and a clear data contract.
Does a shorter query run faster?
Query length is not a reliable measure of work. The chosen execution plan and processed data determine the cost.
What does sargable mean?
A sargable condition can use an index to search, typically a plain column compared with a value: created_on >= '2026-01-01'. Wrapping the column in a function, YEAR(created_on) = 2026, or a calculation usually prevents that, so rewrite such filters as ranges on the raw column.
How do I measure whether a change made a query faster?
Run the query several times before and after the change on realistic data and compare timings, ignoring the first cold run. Check the plan too, with EXPLAIN ANALYZE or your database's equivalent, to confirm the improvement comes from the change you made.
SQL Query Optimization and Index Selection Exercises
Answer each one and check it instantly. If you get stuck, open the hint before the answer.
An October timestamp report needs all times in October. Which boundary is clearest?
SQL Query Optimization and Index Selection Quiz
2 questions to check what you learned.
What must an optimized rewrite preserve?
- The query's required result semantics
- The original whitespace
- The original index name at all costs
Why can joining two one-to-many child tables inflate a total?
- JOIN automatically rounds all monetary values
- Each child combination can repeat the parent and other child values
- SUM always counts each table only once