Performance Optimization — Learn Type Conversion, LIKE, OR/UNION, and Covering Indexes from the Basics
BasicImplicit conversionsPrefix LIKEOR and UNIONCovering indexes / Index Only ScanPostgreSQL5 questions
QUESTION 1
Implicit Type Conversion — Match Column and Value Types to Preserve Index Use
Implicit castData typesSargableIndex Scan
Background

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
Another risk: implicit casts are not merely slow; they are also runtime-error traps. If even one value in the VARCHAR column, such as '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.
Problem

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.

Input Table
employees (emp_code is VARCHAR and indexed)
emp_idemp_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

Expected output:

emp_idemp_codename
21042Bob

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'.

Model Answer
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
  */
Explanation (table transitions & key points)
SELECT emp_id, emp_code, name FROM employees WHERE emp_code = '1042';
LEGEND
Rows read / loaded
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.
1 / 5
emp_idemp_code (VARCHAR)name
1'0958'Alice
2'1042'Bob
3'1107'Carol
4'A201'David
5'2210'Eve
6'0042'Frank
6 rows (real table: 1 million)
LEARNING POINTS
An implicit cast on the column disables the index: for mixed-type comparisons, many databases cast the less specific side—the string—to the more specific side—the number. Thus 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.
Casting the literal instead is harmless: when only the literal is converted, as in 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.
Performance problems can become correctness problems: a numeric comparison against a VARCHAR column containing nonnumeric values can raise a runtime error. Cast-based comparison can also ignore leading zeros, making '0042' unexpectedly equal to 42. Matching data types is both performance tuning and bug prevention.
ANTI-PATTERNS
Ignoring application bind-parameter types: ORMs and drivers frequently pass numeric parameters to string columns, or vice versa. The mismatch is invisible in the SQL text, so inspect EXPLAIN (ANALYZE) with real parameters or review logged execution plans.
Storing numeric-looking codes as numbers: identifiers such as employee codes, postal codes, and phone numbers may contain leading zeros or letters. An integer column loses the zeros in '0042' and rejects 'A201'. Store identifiers as strings and compare them as strings.
Field Notes: Type mismatches occur at schema boundaries
JOIN keys are a common source of implicit-cast failures. In a legacy schema where table A stores 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.
QUESTION 2
LIKE and Prefix Matching — Wildcard Position Determines Index Use
LIKEPrefix matchB-treeRange Scan
Background

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
Memory aid: a phone book can instantly find names beginning with “K,” but finding names that end with “K” requires checking every page. A B-tree works the same way: only a pattern with a fixed beginning can use its ordering.
Problem

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.

Input Table
products (sku is indexed)
product_idskuname
1KB-100Mechanical Keyboard
2MS-200Wireless Mouse
3KB-205Tenkeyless Keyboard
4HS-310Headset
5KB-330Silent Keyboard
6MN-44027-inch Monitor
7CB-001USB-C Cable

Also compare the work required by name LIKE '%Keyboard%', which asks whether the name contains “Keyboard.”

Expected Output

Expected output (sku ascending):

product_idskuname
1KB-100Mechanical Keyboard
3KB-205Tenkeyless Keyboard
5KB-330Silent 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.

Model Answer
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
  */
Explanation (table transitions & key points)
SELECT product_id, sku, name FROM products WHERE sku LIKE 'KB-%' ORDER BY sku;
LEGEND
Rows read / loaded
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.
1 / 5
product_idskuname
1KB-100Mechanical Keyboard
2MS-200Wireless Mouse
3KB-205Tenkeyless Keyboard
4HS-310Headset
5KB-330Silent Keyboard
6MN-44027-inch Monitor
7CB-001USB-C Cable
7 rows (real table: 500,000)
LEARNING POINTS
A prefix match is syntactic sugar for a range: the planner can rewrite 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.”
A leading % removes the index's starting point: '%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.
Collation matters: with some non-C collations, PostgreSQL cannot use an ordinary index for LIKE range conversion. In that case, create the index with the text_pattern_ops operator class. Remember this when a prefix match still produces a Seq Scan.
ANTI-PATTERNS
Building every search as '%' || :keyword || '%': this becomes a predictable time bomb as the table grows. Separate fields that need only prefix matching—codes and IDs—from fields that genuinely need substring or full-text search.
Using UPPER(column) LIKE for case-insensitive search: 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.
Field Notes: Estimate search requirements by match type
The cost and architecture of search depend less on the number of screens than on the type of matching. Exact and prefix matches are the B-tree “free tier.” Substring and fuzzy matching require pg_trgm or full-text search with tsvector; spelling variants and synonyms may require a search engine such as Elasticsearch. Classifying each search box during requirements design prevents the late surprise of adding a leading % and making the query slow. Q3 examines OR conditions that span separate indexed columns.
QUESTION 3
OR Conditions and UNION — Split a Cross-Column OR into Two Index Lookups
ORUNIONBitmap Index ScanRewrite
Background

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 versus UNION ALL: 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.
Problem

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.

Input Table
orders (customer_id and coupon_id indexed separately)
order_idcustomer_idcoupon_idamount
1101NULL1200
21023800
3103NULL2000
410173000
51047500
6102NULL1500
71052700
81037900

order_id=4 satisfies both predicates. A straightforward UNION ALL would return it twice.

Expected Output

Expected output (order_id ascending, no duplicates):

order_idcustomer_idcoupon_idamount
1101NULL1200
410173000
51047500
81037900
Model Answer
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
  */
Explanation (table transitions & key points)
SELECT order_id, customer_id, coupon_id, amount FROM orders WHERE customer_id = 101 UNION SELECT order_id, customer_id, coupon_id, amount FROM orders WHERE coupon_id = 7 ORDER BY order_id;
LEGEND
Rows read / loaded
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.
1 / 5
order_idcustomer_idcoupon_idamount
1101NULL1200
21023800
3103NULL2000
410173000
51047500
6102NULL1500
71052700
81037900
8 rows (real table: 1 million)
LEARNING POINTS
For OR, use IN on one column and split different columns: collapse a same-column OR into 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.
Bitmap Index Scan combines indexes: PostgreSQL converts the row locations from multiple indexes into bitmaps, combines them with BitmapOr or BitmapAnd, and then reads the table. This preserves index use for OR, but becomes less efficient when many rows match and does not preserve index order, so a separate sort may still be required.
UNION deduplication is not free: UNION must sort or hash all selected columns to detect duplicates. If the two branches are logically guaranteed not to overlap—for example, by adding AND customer_id <> 101 to the second branch—UNION ALL removes the entire deduplication cost.
ANTI-PATTERNS
Joining every optional search condition with OR: a giant predicate such as (: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.
Putting OR in a JOIN condition: 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.
Field Notes: EXPLAIN before and after a rewrite
Splitting OR into UNION is powerful, but it is not always faster. If matches cover tens of percent of the table, a Seq Scan may be cheaper than many index lookups. First run 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.
QUESTION 4
WHERE and HAVING — Filter Before or After Aggregation
WHEREHAVINGGROUP BYLogical execution order
Background

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
One-line rule: if a condition does not use an aggregate function, put it in WHERE. Only conditions that cannot be known before SUM, COUNT, AVG, or another aggregate finishes belong in HAVING. This minimizes both aggregation volume and memory use.
Problem

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.

Input Table
sales (category is indexed)
sale_idstore_idcategoryamount
1S1food1800
2S1drink1200
3S2food2500
4S2food900
5S3food800
6S3drink2000
7S1food1500
8S3food1000

Food totals are S1=3300, S2=3400, and S3=1800. Performance depends on whether the two drink rows are removed before aggregation.

Expected Output

Expected output (total descending):

store_idtotal
S23400
S13300
Model Answer
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
  */
Explanation (table transitions & key points)
SELECT store_id, SUM(amount) AS total FROM sales WHERE category = 'food' GROUP BY store_id HAVING SUM(amount) >= 3000 ORDER BY total DESC;
LEGEND
Rows read / loaded
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.
1 / 5
sale_idstore_idcategoryamount
1S1food1800
2S1drink1200
3S2food2500
4S2food900
5S3food800
6S3drink2000
7S1food1500
8S3food1000
8 rows (real table: 5 million)
LEARNING POINTS
Logical execution order determines predicate placement: once FROM → WHERE → GROUP BY → HAVING → SELECT → ORDER BY is familiar, ask “at which stage can this condition be evaluated?” A condition known from one row belongs in WHERE; a condition requiring an aggregate group belongs in HAVING. The same order explains why a SELECT alias such as total is unavailable in WHERE.
Every row removed by WHERE makes all later stages cheaper: early filtering reduces the number of rows aggregated and sorted and shrinks hash tables. If one million of five million rows are food, WHERE cuts aggregation work to one fifth. With a category index, it can also reduce the initial read volume.
Treat HAVING as WHERE for aggregate values: a safe rule is to reserve HAVING for conditions such as 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.
ANTI-PATTERNS
Adding a column to GROUP BY so it can be filtered in HAVING: 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.
Scattering the same aggregate expression through HAVING: a short range such as 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.
Field Notes: Make “push filters upstream” a query-wide habit
WHERE versus HAVING is the simplest form of a broader principle: push filters as close as possible to the data source. The same idea applies before JOINs, inside subqueries, and in view definitions. Planners perform predicate pushdown automatically, but often cannot cross aggregation, DISTINCT, or window functions. Inspect how deep Filter nodes appear in EXPLAIN to find missed opportunities. Q5 applies the next reduction step: avoiding the table heap entirely with Index Only Scan.
QUESTION 5
Covering Indexes — Avoid Heap Reads with Index Only Scan
Covering indexIndex Only ScanINCLUDEI/O reduction
Background

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
Visibility map: PostgreSQL's Index Only Scan consults the visibility map to confirm whether rows are visible. On a frequently updated table, pages may not be marked all-visible, so the executor still performs heap reads. Check Heap Fetches: 0 in EXPLAIN ANALYZE, and run VACUUM when appropriate.
Problem

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.

Input Table
orders (large note column)
order_idcustomer_idamountnote (large)
11011200Gift wrap requested…
2102800Leave at door…
31012000Receipt name…
4103500If absent…
5101700Split shipment…
61021500Delivery window…
7101900Include 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

Expected output (customer_id = 101):

customer_idamount
1011200
1012000
101700
101900

Success means EXPLAIN reports Index Only Scan using idx_orders_cust_amt and Heap Fetches: 0.

Model Answer
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
  */
Explanation (table transitions & key points)
CREATE INDEX idx_orders_cust_amt ON orders (customer_id) INCLUDE (amount); SELECT customer_id, amount FROM orders WHERE customer_id = 101;
LEGEND
Rows read / loaded
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.
1 / 5
order_idcustomer_idamountnote (large)
11011200Gift wrap…
2102800Leave at door…
31012000Receipt…
4103500If absent…
5101700Split shipment…
61021500Delivery window…
7101900Invoice…
7 rows (real table: 1 million; wide rows)
LEARNING POINTS
The hidden cost of Index Scan is heap access: locating a row through an index is only the first step. Retrieving selected columns can require a random heap-page access for every matching TID. The more rows that match—and the wider those rows are—the larger the cost. Covering every required column removes this stage entirely.
INCLUDE stores payload without making it a key: in (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.
The optimization is complete only when Heap Fetches is zero: EXPLAIN ANALYZE may say Index Only Scan while still reporting many Heap Fetches. That usually means the visibility map is not sufficiently populated because VACUUM has not run or rows were recently updated. Judge the plan by the actual Heap Fetches count, not only by its node name.
ANTI-PATTERNS
Trying to cover SELECT *: including every column can theoretically cover the query, but it is equivalent to storing another copy of the table as an index. Every update must maintain the index and its size can approach the table's. Covering indexes are for frequent, narrow queries and fundamentally conflict with SELECT *.
Creating one covering index per query: accumulating similar indexes such as (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.
Field Notes: Five principles for fast SELECT statements
Q1–Q5 can be summarized as five rules: 1. Match column and value types so implicit casts do not disable indexes. 2. Prefer prefix LIKE patterns because wildcard position determines B-tree usability. 3. Handle cross-column OR with BitmapOr or an explicit UNION. 4. Put row predicates in WHERE and only aggregate predicates in HAVING. 5. Use a covering index to eliminate heap access for frequent narrow queries. Together with the ten principles from Performance Basics 001—reading plans, Sargability, column selection and LIMIT, EXISTS, Top-N, composite indexes, JOIN tuning, pre-aggregation, window functions, and keyset pagination—these rules cover most everyday SELECT-tuning work. The second half of Performance Basics 002 continues with subquery shape and execution count, NULL logic, sorting, and memory.