DevLearningTools

LEARN · SQL

SQL Query Optimization Basics

Practical habits that keep a query index-friendly as a table grows: indexing the columns a query actually filters, joins, and sorts on, keeping an indexed column naked in WHERE instead of wrapped in a function, and why a large OFFSET gets slower over time.

This module covered what an index is, how to index more than one column, and how to see whether a query actually used one. This last lesson ties all three together into the habits that keep a query fast as Beacon Robotics' tables keep growing.

Learning Objectives

  • List the columns worth indexing for a given query: the ones in its WHERE, JOIN, and ORDER BY clauses.
  • Explain sargability, and why wrapping an indexed column in a function usually defeats the index on it.
  • Explain why a large OFFSET value gets slower as it grows, and what keyset pagination does differently.

Index WHERE / JOIN / ORDER BY

The columns a query actually uses

Keep It Sargable

Don't wrap the indexed column in a function

Measure With EXPLAIN

Confirm it, don't just assume it

Index the Columns a Query Actually Uses

The two previous lessons already covered this in pieces, bring it together here: a column is worth indexing if a real query filters on it (WHERE), joins on it (ON), or sorts by it (ORDER BY). A column that's only ever selected, never filtered, joined, or sorted on, gets no benefit from an index, it's pure write overhead with nothing to show for it.

Sargability: Keep the Indexed Column Naked

A "sargable" predicate is one the optimizer can resolve directly against an index. Wrapping an indexed column in a function, like YEAR(hire_date) = 2024 or UPPER(last_name) = 'SMITH', usually forces the engine to compute that function for every single row before it can compare anything, which turns what should be an index seek back into a full scan.

Not sargable
SELECT * FROM employees
WHERE YEAR(hire_date) = 2024;
Sargable rewrite
SELECT * FROM employees
WHERE hire_date >= '2024-01-01'
  AND hire_date < '2025-01-01';

When the Function Form Is Unavoidable: Expression Indexes

DatabaseCan Index a Function's Result Directly?
MySQL (8.0.13+) / MariaDB (10.2+)Yes, a functional key part: CREATE INDEX idx ON t((YEAR(hire_date)));
PostgreSQL / SQLite (3.9.0+) / OracleYes, an expression (or function-based) index: CREATE INDEX idx ON t(LOWER(last_name));
SQL ServerNo direct syntax, add a persisted computed column, then index that column instead.
NOTE

This is the real fix when the function genuinely can't be removed from the query, rather than always demanding the predicate be rewritten by hand, the index itself is built on the function's output instead of the raw column.

SELECT * and Index-Only Access

A query that only needs columns already present in an index can sometimes be answered entirely from the index itself, without a separate trip back to the full row at all, PostgreSQL calls this an Index Only Scan, the general term used across MySQL and SQL Server is a covering index. SELECT * defeats this every time, since it forces a return to the full row for every column that isn't part of the index.

Pagination: Large OFFSET vs Keyset Pagination

Module 1 covered LIMIT/OFFSET's syntax differences, here's the performance angle: a large OFFSET still has to walk through and discard every skipped row first, even with an index on the ORDER BY column, so OFFSET 100000 LIMIT 20 gets slower as that offset number grows. Keyset pagination, WHERE id > :last_seen_id ORDER BY id LIMIT 20, stays fast at any depth, since it's a direct indexed seek to a known starting point with nothing discarded along the way.

Gets slower as the offset grows
SELECT * FROM employees
ORDER BY id
LIMIT 20 OFFSET 100000;
Keyset pagination, stays fast at any depth
SELECT * FROM employees
WHERE id > 100000
ORDER BY id
LIMIT 20;

Try It Yourself

Rewrite this non-sargable query so the WHERE clause itself compares the raw hire_date column, on PostgreSQL: SELECT * FROM employees WHERE YEAR(hire_date) = 2023;

Answer
SELECT * FROM employees
WHERE hire_date >= '2023-01-01'
  AND hire_date < '2024-01-01';

Frequently Asked Questions

Does sargability matter if the table is small?

Less so, the same caveat as the earlier "when not to index" point applies, a full scan over a small table is already fast regardless of whether a predicate is sargable.

Is SELECT * always wrong?

No, it's fine for small result sets and quick, ad hoc exploration. The real cost shows up on wide tables, large result sets, or a table with a covering index that SELECT * silently defeats by needing every column.

Common Beginner Mistakes

Wrapping an indexed column in a function in WHERE, and expecting the index to still be used

It usually won't be, unless there's a matching expression index built on that exact function. Rewrite the comparison to leave the column bare where possible, or build the expression index if the function form genuinely can't be avoided.

Using a large OFFSET value for deep pagination on a table that keeps growing

An OFFSET-based page 5,000 gets slower every time the table grows, since every skipped row still has to be walked through first. Keyset pagination, filtering WHERE id > :last_seen_id instead of counting through OFFSET, stays equally fast at page 5 or page 5,000.

Best Practices

  • Index WHERE, JOIN, and ORDER BY columns, specifically the ones a real query uses, confirmed with EXPLAIN rather than assumed.
  • Avoid wrapping an indexed column in a function inside WHERE, rewrite the comparison instead, or build a matching expression index when the function form can't be avoided.
  • Prefer keyset pagination over a large OFFSET for deep pages on a table that keeps growing.

Interview Questions

What does it mean for a predicate to be "sargable", and give a non-sargable example with its sargable rewrite?

A sargable predicate is one the database can resolve directly against an index, without computing anything per row first. WHERE YEAR(hire_date) = 2024 is not sargable, the engine must compute YEAR() for every row before comparing. WHERE hire_date >= '2024-01-01' AND hire_date < '2025-01-01' is sargable, it compares the raw hire_date column directly, letting an index on hire_date be used as a seek.

Why does a large OFFSET value get slower as it grows, even with an index on the ORDER BY column?

The index lets the database find the correct order quickly, but OFFSET still means walking through, and discarding, every row before the requested starting point, that discard work grows linearly with the offset itself. Keyset pagination avoids it entirely by filtering directly to a known starting point (WHERE id > :last_seen_id) instead of counting through rows to skip.

Summary

Index the columns a real query filters, joins, or sorts by, keep predicates sargable so an existing index actually gets used, and prefer keyset pagination over a large OFFSET once a table is big enough for the difference to matter. None of this replaces EXPLAIN, confirm it on the real data rather than assuming it.

That completes Module 13, Indexes and Performance: what an index actually is, indexing more than one column at once, reading a real execution plan, and the query habits that keep an index worth having.

What's Next?

Module 14, Transactions and ACID, is next: grouping multiple statements into one all-or-nothing unit, and what COMMIT, ROLLBACK, and the ACID guarantees actually mean underneath.

</>

Ready to Practice SQL?

Write and run real SQL queries online, right in your browser, no install or signup required. Pick a playground based on which database you want to practice on.

SQLite / PostgreSQL

Fast, no server, no ads.

Open DB Fiddle ↗

MySQL / SQL Server

Switch databases with one dropdown.

Open SQL Fiddle ↗

Both open a third-party SQL playground in a new tab. External site terms and privacy policy apply.

Or try 50 SQL practice questions →