When a WHERE-clause column and its comparison value have different data types, the database automatically tries to reconcile them through an implicit conversion. In many cases, the column side is cast, effectively producing CAST(col) = value. That wraps the column in a function and prevents the index from being used. It is one of the hardest performance traps to spot because the query contains no explicit function call.
-- ✗ emp_code is VARCHAR; comparing it with a numeric literal casts the column WHERE emp_code = 1042 -- Internally: CAST(emp_code AS int) = 1042 → Seq Scan -- ✓ Match the column type with a string literal so the B-tree remains usable WHERE emp_code = '1042' -- Index Scan
'A201', cannot be converted to a number, the entire query fails as soon as that row is cast. This is a classic reason a query works until new data arrives.The emp_code column in the one-million-row employees table is VARCHAR(10) and has an index named idx_emp_code. Retrieve the employee with code 1042 as one row, using a form that allows the index to be used correctly. Return emp_id, emp_code, name.
| emp_id | emp_code (VARCHAR) | name |
|---|---|---|
| 1 | '0958' | Alice |
| 2 | '1042' | Bob |
| 3 | '1107' | Carol |
| 4 | 'A201' | David |
| 5 | '2210' | Eve |
| 6 | '0042' | Frank |
The value 'A201' for emp_id=4 cannot be converted to a number. Also consider what happens if the predicate is written as the numeric comparison emp_code = 1042.
Expected output:
| emp_id | emp_code | name |
|---|---|---|
| 2 | 1042 | Bob |
The correct form reads only a few B-tree leaf blocks with an Index Scan. A numeric-literal comparison instead casts all one million rows and can fail on 'A201'.
SELECT emp_id, emp_code, name FROM employees WHERE emp_code = '1042'; -- Compare with a string literal matching the VARCHAR column /* Execution order: 1. Binary-search the idx_emp_code B-tree for the lexical value '1042' 2. Read the row location (TID) from the matching leaf entry 3. Fetch emp_id, emp_code, and name from the heap */
LEGEND
1. Source table — emp_code is VARCHAR
FROM employeesemp_code is a string column, and its B-tree is ordered lexically. The value 'A201' shows why real identifiers cannot be assumed to be numeric.| emp_id | emp_code (VARCHAR) | name |
|---|---|---|
| 1 | '0958' | Alice |
| 2 | '1042' | Bob |
| 3 | '1107' | Carol |
| 4 | 'A201' | David |
| 5 | '2210' | Eve |
| 6 | '0042' | Frank |
VARCHAR_column = number can become CAST(column) = number, violating Sargability even without an explicit function. If EXPLAIN shows a Seq Scan despite an available index, first check for a type mismatch.integer_column = '1042', the constant is cast once and the index remains usable. What matters is which side is cast. Remove the ambiguity by always passing literals and bind parameters with the same type as the column.'0042' unexpectedly equal to 42. Matching data types is both performance tuning and bug prevention.EXPLAIN (ANALYZE) with real parameters or review logged execution plans.user_id as INT and table B stores it as VARCHAR, each join can disable one side's index. The preferred fix is a migration that aligns the schema types. As a temporary measure, match literals and variables to the column; if a column must be converted, use an expression index such as CREATE INDEX ON b ((user_id::int)). Q2 examines another way an index can be lost even when types match: the position of a LIKE wildcard.A B-tree index is ordered lexically from the first character. Therefore, a prefix match such as LIKE 'KB-%' can be converted internally into the range sku >= 'KB-' AND sku < 'KB.' and use an Index Range Scan. By contrast, LIKE '%KB' (suffix match) and LIKE '%KB%' (substring match) have no known starting point, so the database must evaluate every row.
-- ✓ Prefix match: convertible to a range scan WHERE sku LIKE 'KB-%' -- sku >= 'KB-' AND sku < 'KB.' -- ✗ Substring or suffix match: no starting point, so evaluate every row WHERE name LIKE '%Keyboard%' -- Seq Scan; consider pg_trgm
The sku column in the 500,000-row products table has an index named idx_products_sku. Retrieve keyboard products whose SKU begins with 'KB-' in a form that supports an Index Range Scan, ordered by sku ascending. Return product_id, sku, name.
| product_id | sku | name |
|---|---|---|
| 1 | KB-100 | Mechanical Keyboard |
| 2 | MS-200 | Wireless Mouse |
| 3 | KB-205 | Tenkeyless Keyboard |
| 4 | HS-310 | Headset |
| 5 | KB-330 | Silent Keyboard |
| 6 | MN-440 | 27-inch Monitor |
| 7 | CB-001 | USB-C Cable |
Also compare the work required by name LIKE '%Keyboard%', which asks whether the name contains “Keyboard.”
Expected output (sku ascending):
| product_id | sku | name |
|---|---|---|
| 1 | KB-100 | Mechanical Keyboard |
| 3 | KB-205 | Tenkeyless Keyboard |
| 5 | KB-330 | Silent Keyboard |
A prefix match reads only one contiguous interval of the index. Because index order already matches ascending sku order, no additional sort is required.
SELECT product_id, sku, name FROM products WHERE sku LIKE 'KB-%' -- Prefix match becomes an index range ORDER BY sku; -- Same order as the index; no extra sort /* Execution order: 1. Convert LIKE 'KB-%' into an internal range condition 2. Binary-search the B-tree to reach the beginning of the interval 3. Stop when the prefix changes, after scanning only the matching interval 4. Return rows in index order without a separate sort */
LEGEND
1. Source table — products (sku indexed)
FROM productsThe SKU uses a common real-world scheme: a product-category prefix (KB=keyboard, MS=mouse, and so on) followed by a serial number. This design, in which the beginning carries meaning, works especially well with prefix search.| product_id | sku | name |
|---|---|---|
| 1 | KB-100 | Mechanical Keyboard |
| 2 | MS-200 | Wireless Mouse |
| 3 | KB-205 | Tenkeyless Keyboard |
| 4 | HS-310 | Headset |
| 5 | KB-330 | Silent Keyboard |
| 6 | MN-440 | 27-inch Monitor |
| 7 | CB-001 | USB-C Cable |
LIKE 'KB-%' into a range from 'KB-' up to the next lexical value. Because matching rows form one contiguous B-tree interval, execution becomes “binary-search to the start, read continuously, stop when the prefix changes.”'%xxx' and '%xxx%' require evaluating every row. In PostgreSQL, use the pg_trgm extension with a GIN index for substring search. For suffix-only search, an expression index on reverse() can turn the condition into a prefix match.text_pattern_ops operator class. Remember this when a prefix match still produces a Seq Scan.UPPER(sku) LIKE 'KB-%' wraps the column in a function and disables the normal index, just like Q1. Use ILIKE with an appropriate expression index, the citext type, or normalize values when writing them.An OR on the same column, such as WHERE a = 1 OR a = 2, can be folded into IN (1, 2) and handled by one index. The difficult case is an OR across different columns, such as WHERE a = 1 OR b = 2. A single index cannot determine one interval to scan. There are two remedies: let the planner combine two indexes with Bitmap Index Scan (BitmapOr), or explicitly split the predicate into two index-friendly queries with UNION.
-- Cross-column OR: one index cannot define the complete scan range WHERE customer_id = 101 OR coupon_id = 7 -- UNION rewrite: each branch can use its own index SELECT ... WHERE customer_id = 101 UNION -- Remove a row matched by both branches SELECT ... WHERE coupon_id = 7
UNION removes duplicate rows so that a row satisfying both predicates appears once. UNION ALL avoids deduplication and is faster, but a straightforward OR rewrite can match the same row in both branches. Use UNION by default, or add an exclusion predicate to the second branch before using UNION ALL.The one-million-row orders table has separate single-column indexes on customer_id and coupon_id. Retrieve orders for customer 101 or orders that used coupon 7 as a UNION in which each index remains usable. Sort by order_id ascending and return order_id, customer_id, coupon_id, amount.
| order_id | customer_id | coupon_id | amount |
|---|---|---|---|
| 1 | 101 | NULL | 1200 |
| 2 | 102 | 3 | 800 |
| 3 | 103 | NULL | 2000 |
| 4 | 101 | 7 | 3000 |
| 5 | 104 | 7 | 500 |
| 6 | 102 | NULL | 1500 |
| 7 | 105 | 2 | 700 |
| 8 | 103 | 7 | 900 |
order_id=4 satisfies both predicates. A straightforward UNION ALL would return it twice.
Expected output (order_id ascending, no duplicates):
| order_id | customer_id | coupon_id | amount |
|---|---|---|---|
| 1 | 101 | NULL | 1200 |
| 4 | 101 | 7 | 3000 |
| 5 | 104 | 7 | 500 |
| 8 | 103 | 7 | 900 |
SELECT order_id, customer_id, coupon_id, amount FROM orders WHERE customer_id = 101 -- Branch A: Index Scan on customer_id UNION -- Remove order_id=4, which matches both predicates SELECT order_id, customer_id, coupon_id, amount FROM orders WHERE coupon_id = 7 -- Branch B: Index Scan on coupon_id ORDER BY order_id; -- Sort the complete UNION result /* Execution order: 1. Branch A: use idx_customer_id to fetch customer_id = 101 2. Branch B: use idx_coupon_id to fetch coupon_id = 7 3. UNION the two sets and remove duplicate rows 4. Sort the combined result by order_id */
LEGEND
1. Source table — one index on each column
FROM orderscustomer_id and coupon_id are indexed separately, but one index cannot define the range for customer_id = 101 OR coupon_id = 7.| order_id | customer_id | coupon_id | amount |
|---|---|---|---|
| 1 | 101 | NULL | 1200 |
| 2 | 102 | 3 | 800 |
| 3 | 103 | NULL | 2000 |
| 4 | 101 | 7 | 3000 |
| 5 | 104 | 7 | 500 |
| 6 | 102 | NULL | 1500 |
| 7 | 105 | 2 | 700 |
| 8 | 103 | 7 | 900 |
IN for multiple point lookups through one index. A cross-column OR requires either the planner's BitmapOr or an explicit UNION. If EXPLAIN shows Seq Scan + Filter (a OR b), splitting the query is worth testing.AND customer_id <> 101 to the second branch—UNION ALL removes the entire deduplication cost.(:a IS NULL OR col_a = :a) OR (:b IS NULL OR col_b = :b) can prevent the planner from choosing any useful index. Build SQL from only the conditions that were supplied, or provide separate query shapes for common combinations.ON a.x = b.x OR a.y = b.y commonly prevents Hash Join and Merge Join, leaving a Nested Loop that evaluates many combinations. Split it into a query joining on x and another joining on y, then combine them.EXPLAIN ANALYZE on the original OR and check whether BitmapOr is effective. If it is not, test the UNION form and compare both plans and actual times. Treat rewrites as hypotheses to verify, not as automatic improvements. Q4 moves from where a predicate points to where it belongs: WHERE versus HAVING.SQL's logical execution order is FROM → WHERE → GROUP BY → HAVING → SELECT → ORDER BY. WHERE filters individual rows before aggregation, can use an index, and reduces the number of rows entering the aggregate. HAVING filters groups after aggregation and is the place for conditions on SUM, COUNT, and other aggregate results. Putting a row-level condition in HAVING creates double work: aggregate all rows first, then discard groups that should never have been built.
-- Logical order and the location of each filter FROM sales -- 1. Read data WHERE category = 'food' -- 2. Row filter before aggregation; indexable GROUP BY store_id -- 3. Form groups HAVING SUM(amount) >= 3000 -- 4. Group filter after aggregation
From the five-million-row sales table, aggregate store totals using only sales in the 'food' category, retain stores whose total is at least 3000, and sort by total descending. Put the category filter and the total filter in their correct clauses. Return store_id, total.
| sale_id | store_id | category | amount |
|---|---|---|---|
| 1 | S1 | food | 1800 |
| 2 | S1 | drink | 1200 |
| 3 | S2 | food | 2500 |
| 4 | S2 | food | 900 |
| 5 | S3 | food | 800 |
| 6 | S3 | drink | 2000 |
| 7 | S1 | food | 1500 |
| 8 | S3 | food | 1000 |
Food totals are S1=3300, S2=3400, and S3=1800. Performance depends on whether the two drink rows are removed before aggregation.
Expected output (total descending):
| store_id | total |
|---|---|
| S2 | 3400 |
| S1 | 3300 |
SELECT store_id, SUM(amount) AS total FROM sales WHERE category = 'food' -- Remove rows through the index before aggregation GROUP BY store_id -- Group only the remaining rows HAVING SUM(amount) >= 3000 -- Keep only conditions requiring the aggregate result ORDER BY total DESC; -- Sort the aggregate output /* Execution order: 1. FROM sales → identify the source table 2. WHERE category = 'food' → use the index to retain food rows 3. GROUP BY store_id → group by store and compute SUM 4. HAVING SUM(amount) >= 3000 → remove groups below the threshold 5. SELECT → return store_id and total 6. ORDER BY total DESC → sort the result */
LEGEND
1. FROM sales — all 8 rows (6 food + 2 drink)
FROM salesOn a five-million-row table, aggregation cost depends on how many rows enter it. Remove drink rows before aggregation.| sale_id | store_id | category | amount |
|---|---|---|---|
| 1 | S1 | food | 1800 |
| 2 | S1 | drink | 1200 |
| 3 | S2 | food | 2500 |
| 4 | S2 | food | 900 |
| 5 | S3 | food | 800 |
| 6 | S3 | drink | 2000 |
| 7 | S1 | food | 1500 |
| 8 | S3 | food | 1000 |
SUM(...) >= 3000 that contain an aggregate function. Some databases can push an ordinary row condition from HAVING down into WHERE, but relying on that optimization makes intent less clear and behavior less portable.GROUP BY store_id, category HAVING category = 'food' can return the same result, but it builds groups for every category and then discards them. Extra groups also increase hash-aggregation memory. Put row predicates in WHERE.HAVING SUM(amount) >= 3000 AND SUM(amount) < 10000 is acceptable, but when the same aggregate is repeated in SELECT, HAVING, and ORDER BY, aggregate once in a subquery or CTE and refer to its alias for clarity.A normal Index Scan has two stages: (1) locate row positions (TIDs) through the index, then (2) jump to the table heap to retrieve selected column values. The second stage performs random I/O for every matching row and can become surprisingly expensive. If the index itself contains every column required by the query, those heap visits disappear and PostgreSQL can use an Index Only Scan. Such an index is called a covering index because it covers the complete query.
-- PostgreSQL 11+: INCLUDE stores non-key payload columns in leaf entries CREATE INDEX idx_orders_cust_amt ON orders (customer_id) INCLUDE (amount); -- search key payload only; not part of sort order SELECT customer_id, amount -- Every required column is in the index FROM orders WHERE customer_id = 101; -- Index Only Scan
Heap Fetches: 0 in EXPLAIN ANALYZE, and run VACUUM when appropriate.The one-million-row orders table has a large note text column, making each row wide. Design an index and SELECT statement for the frequent query “list order amounts for one customer, returning only customer_id and amount” so it can run as an Index Only Scan without reading the table heap.
| order_id | customer_id | amount | note (large) |
|---|---|---|---|
| 1 | 101 | 1200 | Gift wrap requested… |
| 2 | 102 | 800 | Leave at door… |
| 3 | 101 | 2000 | Receipt name… |
| 4 | 103 | 500 | If absent… |
| 5 | 101 | 700 | Split shipment… |
| 6 | 102 | 1500 | Delivery window… |
| 7 | 101 | 900 | Include invoice… |
customer_id=101 matches four rows. Even though the query does not use note, a normal Index Scan must visit heap pages containing those wide rows to obtain amount.
Expected output (customer_id = 101):
| customer_id | amount |
|---|---|
| 101 | 1200 |
| 101 | 2000 |
| 101 | 700 |
| 101 | 900 |
Success means EXPLAIN reports Index Only Scan using idx_orders_cust_amt and Heap Fetches: 0.
CREATE INDEX idx_orders_cust_amt -- Cover the search key and returned payload in one index ON orders (customer_id) INCLUDE (amount); SELECT customer_id, amount -- Every selected column is available in the index FROM orders WHERE customer_id = 101; -- Search key is indexed, enabling Index Only Scan /* Execution order: 1. Binary-search idx_orders_cust_amt for customer_id = 101 2. Read amount directly from the matching leaf entries 3. Confirm visibility through the visibility map without visiting the heap */
LEGEND
1. Source table — large rows due to note
FROM ordersThe query needs only customer_id and amount, but a heap read loads pages containing the large, unused note.| order_id | customer_id | amount | note (large) |
|---|---|---|---|
| 1 | 101 | 1200 | Gift wrap… |
| 2 | 102 | 800 | Leave at door… |
| 3 | 101 | 2000 | Receipt… |
| 4 | 103 | 500 | If absent… |
| 5 | 101 | 700 | Split shipment… |
| 6 | 102 | 1500 | Delivery window… |
| 7 | 101 | 900 | Invoice… |
(customer_id) INCLUDE (amount), amount is stored in leaf entries but does not participate in B-tree ordering. Adding it as a key, (customer_id, amount), would also cover this query, but INCLUDE keeps the key shorter and does not change the key columns of a UNIQUE constraint. Put WHERE and ORDER BY columns in keys; put columns returned only by SELECT in INCLUDE.(a) INCLUDE (b), (a) INCLUDE (c), and (a, b) quietly degrades write performance and storage use. First consider consolidating INCLUDE columns into an existing index, and audit unused indexes with pg_stat_user_indexes.