Databases & SQL Interview Questions: Indexes
Reviewed by Mark Dickie · Last updated
14 questions on this page you can answer and see marked on the spot. Go to the first one
A database index is a secondary data structure that lets the query engine find rows without scanning the entire table. For a SQL interview focused on indexes, you should know the trade-off between read speed and write cost, the difference between clustered and non-clustered indexes, and when a covering index lets the engine skip the table lookup entirely. Interviewers also probe whether you can read an execution plan and explain why a query is doing a full scan instead of using the index you expected.
Here's a quick map of the index types you'll be asked about:
| Index type | What it stores | Typical interview question |
|---|---|---|
| B-tree (clustered) | Table rows sorted by key | Why can a table have only one clustered index? |
| B-tree (non-clustered) | Key + pointer to row | When does the optimizer choose a scan over a seek? |
| Covering index | All columns the query needs | How does INCLUDE differ from KEY columns? |
| Hash (memory-optimized) | Hash bucket of key | When does a hash index beat a B-tree? |
| Bitmap | Bitmap of row IDs per value | Why are bitmap indexes rare in OLTP engines? |
What does an index cost, and when should you avoid one?
Every index adds storage and slows writes because inserts, updates, and deletes must maintain each index. The decision to add one comes down to read frequency versus write frequency and whether the query pattern justifies it. You should be able to articulate these trade-offs:
- A write-heavy table with few reads may be better off with no index beyond the primary key.
- A composite index is only useful when the query filters on a leftmost prefix of its columns.
- An index on a low-cardinality column (e.g., a boolean flag) rarely gets picked because the optimizer estimates a scan is cheaper.
- A covering index avoids the key lookup, but adding too many included columns bloats the index and negates the write-cost savings.
- The optimizer may ignore an index if statistics are stale or if the query uses a function on the indexed column, preventing a seek.
How do you debug a query that ignores an index you created?
Start by reading the execution plan: a table scan where you expected an index seek tells you the optimizer saw a cheaper plan. Common root causes include wrapping the indexed column in a function (e.g., WHERE UPPER(name) = 'JANE'), implicit type conversions that change the predicate from a seek to a scan, and statistics that no longer reflect the data distribution. Updating statistics, rewriting the predicate to be sargable, or adding the right included columns usually fixes it. In practice, the ability to explain sargability and leftmost-prefix rules is what separates a junior answer from a senior one in this part of the interview.
Key facts
- Tarmac's Databases & SQL interview questions cover 23 questions at difficulty 1–5 of 5.
- Tarmac tracked 6,625 job postings asking for Databases & SQL in September 2026.
- Roles asking for Databases & SQL advertise a median base salary of US$162,913, across 1,799 job postings as of September 2026.
- Tarmac last reviewed these Databases & SQL interview questions on 13 October 2026.
At a glance
| Questions | 23 |
|---|---|
| Difficulty | 1–5 of 5 |
| Formats | True / false, Fill in the blank, Multiple choice, Short answer, Multiple answer, Flashcard |
What you'll review
- indexes
- btree
- index tradeoffs
- composite index
- covering index
Practice questions
Try one before you open the answer. Pick an option and press Check; it's marked on the spot.
True or false: creating an index on a column generally speeds up SELECT queries that filter on that column, but adds write overhead to INSERT, UPDATE, and DELETE operations, because the index must be maintained whenever the data it covers changes.#
Options
Show answer
True. A database index is a separate structure, usually a B-tree, that lets the database find matching rows without scanning the whole table, so filtered SELECT queries on that column generally get faster. The cost is that every write touching indexed data must also update the index, which slows INSERT, UPDATE, and DELETE and uses extra storage.
A database index is a separate data structure, typically a B-tree, that lets the database locate matching rows without scanning the whole table, so queries that filter on the indexed column usually run faster. The index must stay in sync with the table, so writes that change indexed data also have to update the index, which adds overhead to INSERT, UPDATE, and DELETE. The statement is true because it names both sides of this classic trade-off: faster reads in exchange for slower writes and extra storage.
To create an index named idx_email on the email column of a table named users, you write the following SQL statement: _____ INDEX idx_email ON users(email); Fill in the missing SQL keyword.#
Show answer
To create an index named idx_email on the email column of a table named users, you write the following SQL statement: **CREATE** INDEX idx_email ON users(email); Fill in the missing SQL keyword.
The standard SQL syntax for creating an index is CREATE INDEX <index_name> ON <table_name>(<column_name>);. Filling the blank with CREATE produces the valid statement CREATE INDEX idx_email ON users(email);, which creates an index named idx_email on the email column of the users table.
True or false: a database index improves SELECT query performance but typically increases the cost of INSERT, UPDATE, and DELETE operations on the indexed table.#
Options
Show answer
True. A database index speeds up SELECT queries because the engine can locate matching rows through a lookup structure, such as a B-tree, instead of scanning the entire table. The trade-off is that the index must be maintained on every INSERT, UPDATE, and DELETE on the indexed table, which adds write overhead.
This is true. An index is an auxiliary lookup structure, commonly a B-tree, that lets the engine find matching rows without scanning the whole table, which speeds up reads. The index must also be maintained whenever rows are inserted, updated, or deleted, so write operations pay an extra cost.
A table employees has a composite B-tree index on (last_name, first_name). Assume the database uses only the standard leftmost-prefix seek (no skip-scan optimization). Which query below cannot use this index to avoid a full table scan?#
Options
Show answer
A query that filters only on first_name, such as WHERE first_name = 'John', cannot seek into a composite B-tree index on (last_name, first_name). Because the index is ordered by the leading column last_name, the leftmost-prefix rule requires a filter on that column to avoid a full table scan.
Composite B-tree indexes follow the leftmost-prefix rule: the index can be sought efficiently only when the leading column appears in the filter. The index is ordered by last_name first, then first_name, so a filter on first_name alone has no starting point in the index. The query filtering only on first_name must scan the table, while the queries that filter on last_name alone, on last_name and first_name together, or on a range of last_name all constrain the leading column and can use the index.
A PostgreSQL 17 database has a table employees with a composite B-tree index on (last_name, first_name). Which single-column filter cannot use this index for an index seek (a targeted B-tree traversal directly to matching entries)?#
Options
Show answer
In PostgreSQL 17, a query filtering only on first_name cannot use a composite B-tree index on (last_name, first_name) for an index seek. The index is ordered by last_name first, and that version has no skip scan to bypass a missing leading column. Filters on last_name, alone or combined with first_name, can seek directly.
A B-tree index is ordered by its leading column first and by later columns only within equal leading values, so the index can be traversed directly only when the leading column (last_name) appears in the predicate. A filter on first_name alone omits the leading column; PostgreSQL 17 has no skip scan, so it cannot jump through the index to the matching first_name values and would need a full index scan or a table scan instead. The filters on last_name alone, on a last_name range, and on both columns all constrain the leading column and can seek directly to matching entries.
True or false: adding a B-tree index to a column that frequently appears in WHERE clauses always improves overall query performance.#
Options
Show answer
False. A B-tree index on a frequently filtered column does not always improve overall performance. It speeds up matching reads but adds write maintenance cost and storage. On small tables, low-selectivity columns, or write-heavy workloads, a sequential scan or no index can be faster overall.
The statement is false because of the word 'always'. An index speeds up read lookups on that column, but every INSERT, UPDATE, and DELETE must also maintain the index, which adds write overhead and consumes extra storage and memory. On small tables a sequential scan can be as fast or faster, and on low-selectivity columns the optimizer may still choose a full scan because the index would return too many matching rows. For write-heavy workloads the maintenance cost can outweigh the read gains, so indexing is a trade-off rather than a guaranteed improvement.
A table employees has a composite B-tree index on (dept_id, last_name, first_name). Under the leftmost-prefix rule, which WHERE clause allows the index to seek on every column it filters on?#
Options
Show answer
The WHERE clause filtering on dept_id and last_name lets a (dept_id, last_name, first_name) index seek on every filtered column. Under the leftmost-prefix rule, a seek needs a contiguous prefix of the indexed columns starting at the first one. Any clause that skips dept_id or leaves a gap before a column cannot seek on that column.
A composite B-tree index can seek only on a contiguous leftmost prefix of its columns. The filter on dept_id and last_name matches the prefix (dept_id, last_name), so both columns take part in the seek. Filtering on last_name alone skips dept_id, so no prefix match exists. Filtering on dept_id and first_name skips last_name, which breaks the prefix chain, so first_name cannot be seeked. Filtering on first_name and last_name skips dept_id entirely, so neither column can be seeked.
Which predicate can a standard B-tree index on created_at accelerate with a single index range scan?#
Options
Show answer
WHERE created_at >= '2024-01-01' is the one a B-tree can accelerate with a single range scan. A B-tree stores keys in sorted order, so range and equality predicates on the bare column map to a contiguous slice. Wrapping the column in a function such as EXTRACT or a cast, or using a leading-wildcard LIKE or <>, defeats the index because the stored key order no longer lines up with the predicate.
A B-tree stores keys in sorted order, so range and equality predicates on the bare column (>=, >, <, BETWEEN, =) map to a contiguous slice it can scan. Wrapping the column in a function (EXTRACT, a cast) or using a leading-wildcard LIKE or <> makes the index unusable because the stored key order no longer lines up with the predicate.
A standard B-tree index on name can efficiently satisfy WHERE name LIKE '%son' (a leading-wildcard pattern).#
Options
Show answer
False. A B-tree is ordered by the start of the key, so it can seek prefix patterns like LIKE 'son%' but not leading-wildcard patterns like LIKE '%son', which would require checking every key. Suffix or substring search needs a different approach, such as indexing the reversed string, a trigram index, or full-text search.
A B-tree is ordered by the start of the key, so it can seek prefixes (LIKE 'son%') but not patterns that begin with a wildcard (LIKE '%son'), which would require checking every key. Suffix or substring search needs a different approach, such as indexing the reversed string, a trigram index, or full-text search.
In PostgreSQL 17 (before skip-scan support arrived in version 18), a table orders has a composite B-tree index on (customer_id, order_date, status). Three queries run against this table:#
Show answer
The query filtering on order_date and status (query 3) cannot use the index for a directed seek, because it omits customer_id, the leftmost column of the composite index. A B-tree composite index can only seek when the predicates cover a contiguous leftmost prefix of the indexed columns. The query on customer_id and order_date matches the full prefix (customer_id, order_date), and the query on customer_id and status seeks on customer_id, with status applied as a filter; both can use the index, but query 3 has no leftmost-column constraint, so the planner falls back to a full index scan or a sequential scan.
A composite B-tree index can perform a directed seek only when the WHERE clause constrains a contiguous prefix starting at the leftmost column. The query filtering on customer_id and order_date constrains the full prefix (customer_id, order_date), and the query filtering on customer_id and status constrains customer_id, so both can seek; in the second, status is applied as a filter after the seek. The query filtering on order_date and status never constrains customer_id, so the index cannot narrow the search to a range and PostgreSQL 17 would use a full index scan or sequential scan instead. The determining principle is the leftmost-prefix rule.
In PostgreSQL 16, a table orders has a composite B-tree index on (customer_id, order_date, status). Which of the following queries cannot use this index for an index seek, meaning a directed lookup that starts at the index's leading column?#
Options
Show answer
A query that filters only on order_date and status cannot perform an index seek on a composite B-tree index on (customer_id, order_date, status) in PostgreSQL 16. The leftmost-prefix rule requires a condition on the leading column, customer_id, before the index can be searched directly.
A B-tree index is sorted by its columns in order, so a directed seek requires conditions on a leftmost prefix of the index columns. The query that filters only on order_date and status has no condition on customer_id, the leading column, so PostgreSQL cannot locate a starting point in the tree and at best falls back to scanning the whole index or the table. The other three queries all constrain customer_id (by equality or a range), so they form a valid prefix and can be served by an index seek, with the remaining conditions applied as index or filter conditions.
A B-tree composite index on (customer_id, order_date, status) on a PostgreSQL table orders can efficiently use an index seek for the query SELECT * FROM orders WHERE order_date = '2024-01-01' AND status = 'shipped'; because both order_date and status are columns in the index.#
Options
Show answer
False. Even though both columns are in the composite index, the query omits customer_id — the leftmost key column. A B-tree composite index can only seek when the predicates include a contiguous prefix starting from the first column, so this query cannot efficiently use the index for a seek.
Although order_date and status are both part of the composite index, the index is a B-tree ordered on (customer_id, order_date, status). A B-tree can only seek when the query predicates include a contiguous prefix from the leftmost column. Since customer_id is not constrained, the index cannot be used for a directed seek; the optimizer would typically choose a full table scan or a full index scan.
In PostgreSQL 17 (which has no skip scan), consider a composite B-tree index on (customer_id, order_date, status). For each predicate set below, state whether the planner can use this index for an index seek (navigating directly to matching entries rather than scanning the whole index and filtering afterward), and explain why. Set I: customer_id = 42. Set II: customer_id = 42 AND order_date = '2024-01-15'. Set III: order_date = '2024-01-15' AND status = 'shipped'. Set IV: customer_id = 42 AND status = 'shipped'.#
Show answer
Seekable: Set I, Set II, and Set IV. Set I constrains only customer_id, the leftmost column, so the B-tree can seek directly to that customer's entries. Set II constrains customer_id and order_date, a contiguous leftmost prefix, so the seek narrows to one exact range. Set IV seeks on customer_id; status skips the middle column order_date, so it can only be checked against the index entries inside that customer's range and does not narrow the seek. Not seekable: Set III. It omits the leftmost column customer_id, so the B-tree has no starting point for a seek; PostgreSQL 17 must scan the whole index (or the table) and filter on order_date and status.
A composite B-tree index supports an index seek only when its leading column is constrained, because the tree is ordered by the first column and then by later columns within each prefix. Set I and Set II constrain the leftmost column (and, in Set II, the next column as a contiguous prefix), so the planner can seek to a precise range. Set IV constrains the leftmost column and skips order_date; the seek is on customer_id, and status is evaluated against entries in that range. Set III constrains only later columns, so there is no starting point in the tree and the index must be scanned in full. The version is pinned to PostgreSQL 17 because PostgreSQL 18 added skip scan, which can seek over distinct leading-column values and changes the answer for Set III.
In PostgreSQL, a table orders has a composite B-tree index created with CREATE INDEX idx ON orders(region, country, order_date); and no other indexes. Consider these queries against the table (assume no other useful statistics change the plan choice):#
Show answer
Q3 and Q5 cannot use the index to narrow the search range. By the leftmost-prefix rule, a composite B-tree index can only be used for range narrowing when the query predicates start at the leftmost indexed column and proceed contiguously through the key. Q3 filters on country alone (the second column), skipping region (the leftmost). Q5 filters on order_date alone (the third column), skipping both region and country. Because neither query constrains the leading column(s), the matching index entries are scattered across the entire index, so the optimizer cannot perform a bounded range scan and falls back to a full table scan. Q1 (region only), Q2 (region + country), and Q4 (region + country + order_date) all satisfy the leftmost-prefix rule and can use the index.
Q3 and Q5 are the queries that cannot use the composite index for range narrowing. The leftmost-prefix rule states that a multi-column B-tree index is usable for range filtering only when predicates begin with the first indexed column and continue without gaps. Q3 (WHERE country = 'US') skips the leading region column, and Q5 (WHERE order_date > '2024-01-01') skips both region and country. In both cases the relevant index entries are distributed throughout the entire index rather than within one contiguous range, so a B-tree range scan offers no selectivity benefit and the PostgreSQL optimizer falls back to a sequential (full table) scan. Q1, Q2, and Q4 all start with region — the leftmost column — so they can traverse a bounded slice of the index and do not require a full scan.
A table orders has a composite B-tree index created with CREATE INDEX idx ON orders(region, country, order_date); and no other indexes. Assume the database has no skip-scan optimization. Which query CANNOT use this index to narrow the search range for the indexed columns, forcing a full scan?#
Options
Show answer
A query filtering on country and order_date without region cannot use a composite B-tree index on (region, country, order_date) to narrow its search. B-tree indexes are ordered by the leading column first, so without a predicate on that prefix there is no starting point to seek from, and the engine falls back to a full scan when no skip-scan optimization exists.
A B-tree on (region, country, order_date) is ordered first by region, then by country within each region, then by date. The query that filters only on country and order_date has no predicate on the leading column, so there is no leftmost prefix to seek on, and without skip-scan support the engine must scan the whole index or table. The query filtering on region and order_date still seeks on region, which narrows the rows even though order_date cannot be used as a seek key past the skipped country column. The query with region and country equality plus a date range uses the full prefix, and the query with an IN list on region and equality on country still seeks on the leading columns.
When a single index contains every column a query references (SELECT list, WHERE predicates, JOIN keys, and ORDER BY columns), the database engine can satisfy that query entirely from the index without reading the base table. What is the standard term for an index that 'covers' a query in this way?#
Show answer
A covering index. It is an index that contains all the columns a query needs, so the engine can return results from the index alone (an index-only scan) without fetching rows from the base table or heap.
An index that contains all the columns needed to satisfy a query (columns in the SELECT list, WHERE predicates, JOIN conditions, and ORDER BY clause) lets the database return results directly from the index structure without a separate lookup into the base table (heap or clustered index). This is called a covering index, and the query is said to be covered by it. The benefit is avoiding random I/O to the base table, which is often the most expensive part of executing the query.
What is the primary trade-off of adding more indexes to a heavily-written table?#
Options
Show answer
The trade-off is that every INSERT, UPDATE, and DELETE must also maintain each affected index, slowing writes and consuming extra storage. Indexes speed reads, but each one is an additional structure the database keeps consistent on every write, adding disk and cache pressure. The skill is indexing the columns that selective queries actually filter or join on, not indexing everything.
Indexes speed reads but every index is an extra structure the database must keep consistent on every write, so more indexes mean slower INSERT/UPDATE/DELETE and more disk and cache pressure. The skill is indexing the columns that selective queries actually filter or join on, not indexing everything.
You have a composite B-tree index on (tenant_id, created_at) in that column order. Which queries can use the index to seek rather than scan the whole table?#
Options
Pick every one that applies.
Show answer
Filtering on tenant_id alone, on tenant_id plus a created_at range, and an ORDER BY tenant_id, created_at after a tenant_id filter can all seek — but filtering on created_at alone cannot. A composite index is usable only on a left-to-right prefix of its columns, so skipping the leading tenant_id defeats it. This is the classic left-prefix rule.
A composite index is usable only on a left-to-right prefix of its columns. Filtering on the leading tenant_id, or on tenant_id plus a range on the next column created_at, seeks efficiently, and the index's sort order also satisfies the matching ORDER BY after WHERE tenant_id = 42. Filtering on created_at alone (no tenant_id predicate) skips the leading column, so the index can't seek — this is the classic left-prefix rule.
Walk through how a database locates rows for WHERE email = '[email protected]' using a B-tree index on email, and why that beats scanning the table.#
Show answer
The B-tree is a balanced, sorted tree. The engine starts at the root and at each level compares the search key against the separator keys to pick the single child to descend into, walking root → branch → leaf in O(log n) steps instead of reading every row. The matching leaf entry holds a pointer (row id / primary key) to the actual row, which the engine then fetches. A full scan would read all N rows; the index turns that into a handful of page reads down the tree's height.
A B-tree index is sorted and balanced, so a lookup descends from the root through internal nodes to a leaf in logarithmic time, comparing the key at each level to choose one child. The leaf holds (or points to) the row location, so the engine reads only the few pages on that path instead of the whole table. The cost is O(log n) page reads versus O(n) for a sequential scan — the reason an equality or range predicate on an indexed column is so much faster.
What is a covering index, and why is it faster than a normal index lookup?#
Show answer
A covering index contains every column a query needs (in its key or, in PostgreSQL, an INCLUDE clause), so the query is answered entirely from the index. This gives an index-only scan that skips the extra heap/table fetch a normal index lookup needs to retrieve the remaining columns.
Normally an index points back to the table row to fetch columns it doesn't store, costing an extra read per match. When the index already holds all referenced columns, the engine does an index-only scan and never touches the table — a big win for hot, selective queries.
In PostgreSQL 17 (which has no B-tree skip scan), you have a 50-million-row table orders with the composite B-tree index CREATE INDEX idx_orders ON orders(customer_id, status, created_at). Consider these four WHERE clauses, each used in a query on orders:#
Show answer
Ranking: Q1, then Q2, then Q3, then Q4. Q1 seeks on 3 columns: the two equality predicates on customer_id and status fix a single position in the B-tree, and the range on created_at then bounds the scan within that narrow slice. Q2 seeks on 1 column: customer_id is an equality, but status has no predicate, so created_at cannot be used as a seek key and is only checked against each entry under customer_id = 42. Q3 seeks on 1 column: the range on customer_id bounds the scan, but a range on the first column stops further seeking, so status is only checked as a filter across the whole customer_id > 42 range. This is a wider scan than Q2 because the leading predicate is a range rather than an equality. Q4 seeks on 0 columns: the leftmost column customer_id has no predicate, so the B-tree has no entry point and the index cannot be navigated; without skip scan (PostgreSQL 17) the planner would use a different access path. The leftmost-prefix rule requires each column used for seeking to be preceded by equality predicates, and a range predicate ends seeking for the columns after it.
A composite B-tree can seek on a column only if every column to its left has an equality predicate, with the first column being constrained at all. Q1 has equalities on customer_id and status followed by a range on created_at, so all three columns are seek keys and the scan covers only the matching slice. Q2 has an equality on customer_id, but the missing status predicate means created_at is not anchored and is only a post-visit filter, so the seek count is 1. Q3 has a range on the leading column, which bounds the scan but prevents status from being a seek key, so the seek count is 1 and the scanned range is wider than Q2's. Q4 has no predicate on the leading column, so there is no seek entry point and the seek count is 0; PostgreSQL 17 has no skip scan to work around this, though PostgreSQL 18 can sometimes do so.
In PostgreSQL, you have a B-tree index on orders(created_at), where created_at is of type timestamp. Write a concrete WHERE clause that lets the planner use this index for an index range scan (rather than a full table or full index scan). The predicate must apply a range operator directly to the bare created_at column, with no function or expression wrapped around it.#
Show answer
WHERE created_at >= '2024-01-01' AND created_at < '2024-02-01'. This applies range operators directly to the indexed column created_at, so the planner can descend the B-tree to the first matching key and scan forward through the leaf pages. A single bound such as WHERE created_at > '2024-01-01' would also qualify.
A B-tree index on created_at supports range scans when the WHERE clause applies a range predicate (>, >=, <, <=, or BETWEEN) directly to the indexed column. For example, WHERE created_at > '2024-01-01' lets the optimizer descend the tree to the first leaf entry whose key satisfies the predicate, then scan forward through the leaf pages, returning only matching rows. Equality (created_at = ...) can also use the index, but it is a point lookup rather than a range scan. Predicates on other columns, or expressions wrapping created_at (for example date_trunc(...)), prevent a direct range seek unless a matching expression index exists. Note that the planner may still choose a sequential scan on small tables or when many rows match, so the clause only makes the index usable.
In PostgreSQL's cost-based optimizer, the per-column correlation statistic (from pg_stats, range -1.0 to 1.0) reflects how closely the physical heap order matches the index's key order. For a single-column B-tree index, which statement accurately describes its effect on the planner's choice between a plain Index Scan and a Bitmap Heap Scan?#
Options
Show answer
In PostgreSQL's planner, the correlation statistic affects only the estimated cost of a plain Index Scan: a value near 1 or -1 lowers its heap I/O estimate because matching rows sit in nearly index order. The Bitmap Heap Scan cost estimate does not use correlation, so a low value does not penalize it through that statistic.
Correlation only enters the cost of a plain Index Scan. In cost_index() (via the B-tree cost estimator), the planner interpolates the heap page-fetch cost between a best case (sequential, when the heap is in index order) and a worst case (one random fetch per tuple) using the squared correlation. Because it is squared, a correlation of -1.0 is treated exactly like 1.0, so a high-magnitude value of either sign lowers the estimated heap I/O. The Bitmap Heap Scan estimate (cost_bitmap_heap_scan()) instead estimates distinct heap pages fetched from the selectivity and table size, and does not use correlation. Therefore a near-0 correlation does not make the plain index scan cheaper, a low correlation does not raise the bitmap estimate, and a negative correlation is not a worst case.
Sources
The official documentation these questions are checked against:
Related interview questions
Job market
See databases-sql salaries and hiring demand from live job postings.
Practise these until they stick
That's every question we hold on this topic, and the page marks what you pick. What it can't do is remember. A free account keeps every answer, and what you miss comes back until it's right: after a day, then at longer gaps.
Free · the whole bank · 100 marked answers per 30 days · written feedback on the paid plan