skip to content

Indexing and Query Performance

The performance habits a query author controls: sargable predicates, avoiding implicit casts, pruning columns instead of SELECT *, choosing between EXISTS, IN and JOIN, keyset pagination, batched DML, and reading EXPLAIN on your own query. Interviewers ask because a slow query is usually fixed by rewriting it, not by adding one more index.

part ofSQLoverview, primer and where to startread it →
on this pageshow

questions

page 1 of 2

Why compare a VARCHAR account_no column to '12345' rather than to the number 12345?

level: juniorimportance: must knowfreq 62%

answer

  1. The two sides are not the same type
  2. Standard SQL calls them not comparable
  3. Someone has to be converted — which one?
  4. A conversion on the column is per-row
  5. Quote the literal, keep the column bare

basics

~20 s

Because the types must match. Comparing a character column to a numeric literal is not a valid comparison in standard SQL: engines either reject it or silently convert every row's stored value, which throws away the index on that column.

solid answer

~50 s

Character and numeric types are not mutually comparable in standard SQL, so `account_no = 12345` is either rejected or quietly repaired by the engine. PostgreSQL rejects it (`operator does not exist: character varying = integer`). Oracle and SQL Server apply data-type precedence, where numeric outranks character, and convert the **column** to a number; MySQL compares both operands as floating-point numbers. Either way a conversion is evaluated for every row, so the predicate is no longer a plain comparison against the stored column value and a plain index on `account_no` cannot be used to seek. Writing `account_no = '12345'` keeps the column bare and the index usable. It also keeps the comparison honest: read as numbers, `'00123'`, `'123'` and `'123 '` all collapse onto 123, which is rarely what a business key is supposed to mean.

code

sql · 11 lines
sql
-- account_no is VARCHAR(20)

-- BAD: numeric literal forces a per-row conversion of the column
SELECT customer_id, balance
FROM   accounts
WHERE  account_no = 12345;

-- GOOD: character literal matches the column's type; index on account_no is usable
SELECT customer_id, balance
FROM   accounts
WHERE  account_no = '12345';

go deeper

for a junior

Remember the rule you can apply every day: the literal's type must match the column's type. Quote strings, leave numbers unquoted, and never assume the database will sort it out for you.

for a middle

Be ready to explain what the engine actually does — reject it, or convert one side — and why converting the column is evaluated once per row and makes the index on that column useless.

for a senior

Expect to be asked how this reaches production undetected: it passes on small dev data, shows only as a scan in the plan, and often arrives via a driver binding rather than hand-written SQL. Show how you would find every instance.

for a principal

The angle to own is prevention: type mismatches are a symptom of columns declared as text because it was convenient. Argue for correct column types and reviewable query conventions rather than case-by-case query firefighting.

## The comparison the standard will not make SQL is a typed language, and the standard only defines comparison between *mutually comparable* types: character strings compare with character strings, numbers with numbers. A predicate that puts a `VARCHAR` column on one side and an integer literal on the other has no defined meaning. Engines therefore take one of two routes — refuse the statement, or silently insert a conversion so that both sides end up in one type. ## What real engines do PostgreSQL is the strict one. `SELECT * FROM accounts WHERE account_no = 12345` on a `varchar` column fails with `ERROR: operator does not exist: character varying = integer`, because no such operator is defined and the untyped-literal machinery cannot rescue a literal that is already unambiguously an integer. MySQL is the permissive one: when a string column is compared with a number, the comparison is performed as floating-point numbers, so both sides are converted to `DOUBLE`. Oracle and SQL Server publish a data-type precedence order in which numeric types outrank character types, so the *character* operand — the column — is converted to the numeric type. In a SQL Server execution plan this shows up as a `CONVERT_IMPLICIT` wrapper around the column in the predicate; in Oracle you see `TO_NUMBER("ACCOUNT_NO")`. The portable lesson: do not rely on any of this. Write the literal in the column's own type. ```sql -- ambiguous at best, a table scan at worst SELECT * FROM accounts WHERE account_no = 12345; -- unambiguous, index-friendly, portable SELECT * FROM accounts WHERE account_no = '12345'; ``` ## Why the index disappears An index on `account_no` is built over the values as stored: character strings, in character order. A predicate that must compute a numeric conversion of `account_no` for every row is asking a question about a *derived* value that the index does not contain. Nor does the conversion preserve order — as text `'9'` sorts after `'12345'`, as numbers 9 sorts before 12345 — so the engine cannot even walk a range of the index and be sure it covered the matches. Its only correct option is to read every row and convert it. On a small development table that costs nothing; on ten million production rows it is the difference between a millisecond and a minute, which is exactly why this defect reaches production with "it worked fine in dev" attached. ## The correctness half of the bug Even when it is fast enough, the numeric reading of a character key is lossy. Identifiers routinely carry meaningful leading zeros (`'00123'`), padding, or non-numeric forms (`'INV-2024-1'`). Converting them to numbers destroys the first two and blows up on the third: an engine that coerces the column can raise a conversion error on a row your query never intended to look at, because SQL does not promise to evaluate one predicate before another. MySQL's floating-point route is worse in a quieter way — a stored `'12345abc'` converts to 12345 with a warning and compares equal to the literal. ## The fix, and the better fix The immediate fix is one character pair: quote the literal so it is a character-string literal, and the column stays bare. In application code the same rule applies to parameters — bind the value with the type that matches the column, not whatever type the value happens to have in your program. The better fix is upstream. If a column is declared `VARCHAR` but genuinely holds nothing but integers, and queries keep comparing it to numbers, change the column's type. One DDL change beats every future query having to defend itself, and it lets the database enforce what the data actually is. ## How to spot it Three signals. First, review: an unquoted numeric literal next to a column you know is character-typed. Second, the plan: any conversion function wrapped around a column inside a predicate — `CONVERT_IMPLICIT`, `TO_NUMBER`, an explicit cast — means the index on that raw column is out of play. Third, the symptom: a full scan on a column that definitely has an index and definitely is selective. Note that the reverse mismatch, a *numeric* column compared to a quoted string, is usually harmless: there the engine converts the single constant, once, and the column stays bare.

  • Is the reverse case, an INTEGER column compared to the quoted literal '42', equally harmful?
    Usually not. There the engine converts a single constant once, and the column stays bare, so an index on it remains usable — PostgreSQL simply resolves the untyped literal as an integer. The habit of matching types is still worth keeping, because a string that is not a valid number turns a harmless comparison into a runtime conversion error.
  • The column is VARCHAR but every value in it is numeric. Should you cast the column in the query or change the schema?
    Change the schema. Casting the column in the query fixes one statement and leaves every future one exposed, and it keeps the index unusable. Altering the column to an integer type makes the comparison natural, lets the database reject non-numeric data, and shrinks both the column and its index. Do it as a migration, after checking every stored value converts.
  • How do you notice this in an execution plan?
    Look at the predicate the plan prints, not just the operator. Any conversion wrapped around a column — CONVERT_IMPLICIT in SQL Server, TO_NUMBER in Oracle, an explicit cast in PostgreSQL — means the plan is filtering on a derived value, so an index on the raw column cannot drive the access. Seeing a full scan on a selective, indexed column is the corroborating symptom.

Looking up a name in a phone book is fast because the book is sorted by name. Asking for "every entry whose name, read as a number, equals 12345" forces you to read the whole book — the ordering you paid for no longer answers the question.

saying these in an interview costs you the question

  • Says the database just converts it, so it does not matter
  • Assumes the literal is converted, never the column
  • Thinks quoting a number is a style preference
  • Claims every engine handles the mismatch the same way
  • Fixes it by casting the column instead of the literal

context

open as a page

Why does LIMIT 20 OFFSET 200000 get slower as the offset grows?

level: juniorimportance: must knowfreq 72%

basics

~20 s

OFFSET is scan-and-discard. The engine still produces the first 200,000 rows in ORDER BY sequence and throws them away before emitting 20, so the work grows with the offset and deep pages get steadily slower.

open as a page

On a table indexed only on id, why is ORDER BY id LIMIT 10 instant but ORDER BY last_name LIMIT 10 slow?

level: juniorimportance: must knowfreq 55%

basics

~20 s

The index on id already holds rows in id order, so the engine walks it and stops after ten entries. Nothing indexes last_name, so every row must be read and sorted before the first ten are known.

open as a page

Beyond typing convenience, what does SELECT * cost compared with listing the columns you need?

level: juniorimportance: must knowfreq 78%

basics

~20 s

SELECT * projects every column, so the engine reads and ships bytes the caller discards, gives up access paths a narrower projection would allow, and returns a result whose shape silently changes when the table changes.

open as a page

Why does one set-based UPDATE beat a loop that issues one UPDATE per row?

level: middleimportance: must knowfreq 70%

basics

~20 s

A loop pays a network round trip, a parse and a statement execution per row, and often a commit per row. One set-based UPDATE pays all of that once and lets the engine work through the whole row set in bulk.

open as a page

Which of EXISTS, IN, or INNER JOIN with DISTINCT scales best for filtering customers that have orders?

level: middleimportance: must knowfreq 72%

basics

~20 s

EXISTS and IN both express a semi-join: each outer row is kept once, as soon as one match is found. INNER JOIN emits every matching pair, so it needs a DISTINCT that sorts or hashes the whole result afterwards.

open as a page

How do you rewrite LIMIT/OFFSET paging as a keyset (seek) query?

level: middleimportance: must knowfreq 66%

basics

~20 s

Remember the sort-key values of the last row shown and filter on them instead of counting rows: WHERE (created_at, id) < (:last_created_at, :last_id) ORDER BY created_at DESC, id DESC LIMIT 20. Every page then costs the same as page one.

open as a page

Which ORDER BY lists can an index on orders(customer_id, created_at) supply without a sort?

level: middleimportance: must knowfreq 62%

basics

~20 s

Those whose keys form a leading prefix of the index keys, in the same sequence and with consistent directions: ORDER BY customer_id, and ORDER BY customer_id, created_at. Adding an equality filter on customer_id also frees ORDER BY created_at alone.

open as a page

How do you make WHERE email LIKE '%@example.com' index-friendly?

level: middleimportance: must knowfreq 60%

basics

~20 s

Turn the suffix match into a prefix match. Either store the part you actually search on in its own column and compare with =, or keep an indexed reversed copy of the value and match the reversed pattern, which anchors the search at the start of the string.

open as a page

How do you rewrite WHERE city = 'Berlin' OR zip = '10115' so each predicate can use its index?

level: middleimportance: must knowfreq 55%

basics

~20 s

Split the OR into two separate SELECT statements, each with a single-column predicate, and combine them with UNION. Each branch can then be served by its own index, while UNION removes the rows that satisfy both conditions and would otherwise appear twice.

open as a page

In an EXPLAIN plan, what is the difference between an Index Cond and a Filter?

level: middleimportance: must knowfreq 62%

basics

~20 s

An index condition positions the index scan, so only matching entries are visited. A filter is evaluated on every row the scan already produced, and rows failing it are thrown away — work you paid for and discarded.

open as a page

How do you rewrite WHERE CAST(created_at AS DATE) = DATE '2024-03-01' so it stays sargable?

level: middleimportance: must knowfreq 72%

basics

~20 s

Replace the truncation with a half-open range on the bare column: created_at >= DATE '2024-03-01' AND created_at < DATE '2024-03-02'. That matches every instant in the day, leaves the column unwrapped, and avoids the endpoint bugs a BETWEEN on timestamps causes.

open as a page

How does SELECT * behave in a view and in INSERT ... SELECT when the base table gains a column?

level: middleimportance: must knowfreq 50%

basics

~20 s

A view's star is normally expanded and stored when the view is created, so the view keeps its original columns and drifts from the table. INSERT ... SELECT * matches by position, so it fails on a count mismatch or lands values in the wrong columns.

open as a page

How do you write a DELETE that purges 50 million old rows in bounded chunks?

level: seniorimportance: must knowfreq 55%

basics

~20 s

Standard SQL's DELETE has no LIMIT, so bound each chunk with a predicate: delete rows whose key falls in a fixed window, or whose ids come from an ordered FETCH FIRST subquery. Repeat the statement until it reports zero affected rows.

open as a page

Why is a multi-row INSERT ... VALUES faster than 50,000 single-row INSERT statements?

level: juniorimportance: should knowfreq 60%

basics

~20 s

One statement carrying 1,000 rows costs one round trip, one parse and one execution; 1,000 separate INSERTs cost all of that a thousand times. The per-row writing and index maintenance is unchanged — only the per-statement overhead disappears.

open as a page

Why prefer EXISTS over comparing SELECT COUNT(*) to zero when testing whether a matching row exists?

level: juniorimportance: should knowfreq 48%

basics

~20 s

COUNT must visit every matching row to produce a total; EXISTS only has to find one and can stop there. Both answer the same yes/no question, but COUNT's work grows with the number of matches while EXISTS's does not.

open as a page

Why is adding SELECT DISTINCT to make duplicate rows disappear a performance smell?

level: juniorimportance: should knowfreq 55%

basics

~20 s

SELECT DISTINCT deduplicates the entire result after the joins have already run, so the engine sorts or hashes every row produced just to throw most of them away. It hides the real cause, usually a fan-out join, instead of removing duplicates at the source.

open as a page

Does prefixing a statement with EXPLAIN run it, and what does EXPLAIN ANALYZE do differently?

level: juniorimportance: should knowfreq 50%

basics

~20 s

Plain EXPLAIN does not execute the statement; it returns the execution plan the engine would use instead of any rows. EXPLAIN ANALYZE really runs the statement and reports measured timings, so on an UPDATE or DELETE it changes data.

open as a page

What makes a WHERE predicate sargable, and what shape must the filtered column take?

level: juniorimportance: should knowfreq 62%

basics

~20 s

A predicate is sargable when the column appears bare on one side of a comparison and the other side evaluates to a constant, so the engine can turn it into an index range. Wrapping the column in a function or arithmetic destroys that.

open as a page

How do you archive rows into a history table and delete them without round-tripping through the application?

level: middleimportance: should knowfreq 45%

basics

~20 s

Use INSERT INTO archive ... SELECT ... FROM source, then a DELETE over the identical row set, both server-side in one transaction. Pin that set — capture the keys once — so the DELETE cannot remove rows the INSERT never copied.

open as a page

In SQL, how do you apply 100,000 per-row updates from a staging table in one statement?

level: middleimportance: should knowfreq 50%

basics

~20 s

Bulk-load the changes into a staging table keyed on the target's join column, then run one UPDATE that reads from it — portably, a scalar subquery in SET plus a WHERE EXISTS guard so target rows with no staging match are not overwritten with NULL.

open as a page

Is EXISTS always faster than IN with a subquery, or is that rule of thumb outdated?

level: middleimportance: should knowfreq 60%

basics

~20 s

It is outdated as a blanket rule. For an existence filter both express the same semi-join, and cost-based optimizers commonly produce the same plan for either. The honest answer is that the shape you write is a hint, and the plan decides.

open as a page

Comparing INTEGER id to '42' stays index-friendly but VARCHAR code to 42 does not — why?

level: middleimportance: should knowfreq 52%

basics

~20 s

Whichever side is converted decides. Converting a constant happens once and leaves the column bare, so its index still works. Converting the column happens per row and produces a derived value no index on that column stores.

open as a page

Why must a keyset pagination ORDER BY end with a unique column?

level: middleimportance: should knowfreq 52%

basics

~20 s

Without a unique final sort column the order has ties, so the page boundary is ambiguous: a strict comparison skips every remaining row sharing the boundary value, a non-strict one repeats rows already shown. A unique tie-breaker makes the ordering total and the anchor exact.

open as a page

Why can an index on last_name not supply the order for ORDER BY LOWER(last_name)?

level: middleimportance: should knowfreq 40%

basics

~10 s

The index stores the sort order of last_name, not of LOWER(last_name), and lowercasing can reorder values under a case-sensitive collation. Since the engine cannot assume a function preserves order, it sorts the result instead.

open as a page

Why can an index on events(created_at, id) not serve ORDER BY created_at DESC, id ASC without sorting?

level: middleimportance: should knowfreq 44%

basics

~20 s

An index scan yields key order forwards or its exact reverse backwards, nothing else. The exact reverse of (created_at ASC, id ASC) is (created_at DESC, id DESC); a list that flips one key but not the other is neither, so a sort is added.

open as a page

What goes wrong when an application sends WHERE id IN (...) with 50,000 literal values?

level: middleimportance: should knowfreq 42%

basics

~20 s

A huge literal IN list makes the statement text enormous, so it must be transmitted and parsed on every call, each different list length is a different statement, some engines impose a hard limit on list size, and row estimates degrade. Pass the set as a table and join instead.

open as a page

How do you confirm from an EXPLAIN plan that your query used the index you created for it?

level: middleimportance: should knowfreq 48%

basics

~20 s

Read the scan node: it names the index it went through. PostgreSQL prints 'Index Scan using idx_name on table'; MySQL's EXPLAIN reports the chosen index in its key column. A different name, or no index at all, means yours was not used.

open as a page

Why is WHERE price * 1.2 > 120 non-sargable, and how do you rewrite it?

level: middleimportance: should knowfreq 50%

basics

~20 s

The column sits inside an arithmetic expression, so the engine must compute price * 1.2 for every row instead of seeking a range. Move the arithmetic to the constant side — WHERE price > 120 / 1.2 — leaving the column bare.

open as a page

How do you shrink a query's SELECT list to fit an index you already have, and what do you gain?

level: middleimportance: should knowfreq 55%

basics

~20 s

List only columns the index already stores, counting columns used in WHERE, ORDER BY and GROUP BY too. If nothing outside the index is referenced, the engine can answer from the index instead of fetching each matching table row.

open as a page

showing 1–30 of 41