LEVEL 06

Performance & Optimization

Your Oracle DBA background gives you a head start. This level formalizes it in SQL and extends it to PostgreSQL.

Read the execution plan first

-- Oracle
EXPLAIN PLAN FOR
SELECT * FROM transactions WHERE account_id = 'ACC001';
SELECT * FROM TABLE(DBMS_XPLAN.DISPLAY);

-- PostgreSQL
EXPLAIN ANALYZE
SELECT * FROM transactions WHERE account_id = 'ACC001';

-- MySQL
EXPLAIN
SELECT * FROM transactions WHERE account_id = 'ACC001';
Plan keywordMeaningGood or bad?
TABLE ACCESS FULL / Seq ScanFull table scan — reads every rowBad on large tables
INDEX RANGE SCANUsing an index to find rowsGood
INDEX ONLY SCANReads only the index, not the tableBest
HASH JOINGood for large unsorted joinsUsually fine
NESTED LOOPGood when one side is very smallGood for small sets

Indexes — the most impactful lever

-- B-tree (default — covers =, <, >, BETWEEN, LIKE 'prefix%')
CREATE INDEX idx_txn_date ON transactions(txn_date);

-- Composite — column ORDER matters
CREATE INDEX idx_branch_date ON transactions(branch, txn_date);
-- Helps: WHERE branch=? AND txn_date=?
-- Helps: WHERE branch=?
-- Does NOT help: WHERE txn_date=?  (leading column missing)

-- Bitmap — Oracle only, for low-cardinality columns like status
CREATE BITMAP INDEX idx_status ON transactions(status);

-- Partial index — PostgreSQL (index only matching rows)
CREATE INDEX idx_failed ON transactions(account_id) WHERE status='FAILED';

-- Function-based index
CREATE INDEX idx_upper_city ON accounts(UPPER(city));
-- Now: WHERE UPPER(city)='MUMBAI' uses the index

Anti-patterns that bypass indexes

-- ✗ Function on indexed column — full scan
WHERE ROUND(amount, 0) = 5000
WHERE EXTRACT(YEAR FROM txn_date) = 2024
WHERE TO_CHAR(txn_date, 'YYYY') = '2024'

-- ✓ Rewrite to use index
WHERE amount BETWEEN 4999.5 AND 5000.5
WHERE txn_date >= DATE '2024-01-01' AND txn_date < DATE '2025-01-01'

-- ✗ Leading wildcard — full scan
WHERE name LIKE '%Kumar'
-- ✓ Prefix match uses index
WHERE name LIKE 'Kumar%'

-- ✗ Implicit type conversion
WHERE amount = '5000'   -- amount is NUMBER, passing VARCHAR
-- ✓
WHERE amount = 5000

Filter early — execution order tuning

-- SLOW: 10M rows joined, then filtered
SELECT a.customer, SUM(t.amount)
FROM   transactions t
JOIN   accounts     a ON t.account_id = a.account_id
WHERE  EXTRACT(YEAR FROM t.txn_date) = 2024  -- no index use
GROUP BY a.customer;

-- FAST: aggregate inside CTE first, join small result
WITH txn_2024 AS (
    SELECT account_id, SUM(amount) AS total
    FROM   transactions
    WHERE  txn_date >= DATE '2024-01-01'   -- index works
    AND    txn_date <  DATE '2025-01-01'
    AND    status = 'SUCCESS'
    GROUP BY account_id
)
SELECT a.customer, t.total
FROM   accounts  a
JOIN   txn_2024  t ON a.account_id = t.account_id;

Partitioning

-- Oracle: range partition by date
CREATE TABLE transactions (txn_id NUMBER, txn_date DATE, amount NUMBER)
PARTITION BY RANGE (txn_date) (
    PARTITION p_2024_q1 VALUES LESS THAN (DATE '2024-04-01'),
    PARTITION p_2024_q2 VALUES LESS THAN (DATE '2024-07-01'),
    PARTITION p_2024_q3 VALUES LESS THAN (DATE '2024-10-01'),
    PARTITION p_2024_q4 VALUES LESS THAN (DATE '2025-01-01')
);

-- Partition pruning — only scans Q1 partition
SELECT SUM(amount) FROM transactions
WHERE  txn_date >= DATE '2024-01-01'
AND    txn_date <  DATE '2024-04-01';    -- ✓ pruning works

-- ✗ Pruning FAILS — function wraps the partition column
WHERE  TRUNC(txn_date, 'MM') = DATE '2024-01-01'

Materialized views

-- Oracle: pre-compute daily branch summary
CREATE MATERIALIZED VIEW mv_branch_daily
BUILD IMMEDIATE
REFRESH COMPLETE ON DEMAND
AS
SELECT branch, TRUNC(txn_date) AS txn_day,
       COUNT(*) AS txn_count, SUM(amount) AS total_volume
FROM   transactions
GROUP BY branch, TRUNC(txn_date);

EXEC DBMS_MVIEW.REFRESH('MV_BRANCH_DAILY');  -- manual refresh

-- PostgreSQL
CREATE MATERIALIZED VIEW mv_branch_daily AS
SELECT branch, txn_date::date AS txn_day,
       COUNT(*) AS txn_count, SUM(amount) AS total_volume
FROM   transactions GROUP BY branch, txn_date::date;

CREATE INDEX ON mv_branch_daily(branch, txn_day);  -- index the MV too
REFRESH MATERIALIZED VIEW CONCURRENTLY mv_branch_daily;

6 anti-patterns cheat sheet

Anti-patternProblemFix
Function on indexed colBypasses indexUse range instead
SELECT *Pulls all columns, slowName your columns
HAVING instead of WHEREGroups all rows firstFilter before GROUP BY
Correlated subquery in SELECTRuns N timesUse JOIN or CTE
DISTINCT to fix bad joinHides fan-out bugUse EXISTS instead
ORDER BY inside subquerySort gets discardedOnly ORDER BY in outer query

Practice Problems

Q1 · Easy
Write this query then explain what EXPLAIN PLAN shows and what index fixes it: WHERE TRUNC(txn_date) = DATE '2024-03-15' AND branch = 'Mumbai'. Rewrite it to be index-friendly. What composite index would you create?
Q2 · Medium
50M row transactions table. Three hourly queries: A) WHERE status='FAILED', B) WHERE account_id=? AND txn_date>?, C) WHERE branch=? AND status=? AND txn_date BETWEEN ? AND ?. Design the minimum set of indexes to cover all three. Explain column order and why.
Q3 · Medium
Identify every anti-pattern in this query then rewrite it: correlated subqueries in SELECT, implicit cross join with comma, UPPER() in WHERE, TO_CHAR() on date, DISTINCT to fix a fan-out.
Q4 · Hard
Design a partitioning strategy for 500M row transactions (3 years). What type? What column? How many partitions? Write CREATE TABLE DDL. Write one query that guarantees pruning and one that accidentally disables it.
Q5 · Hard
A report runs 200×/day and takes 18 seconds each time (3600s daily DB load). Create a materialized view for it, add an index on the MV, write the refresh strategy, and show the rewritten query that uses the MV.