1. Constraints: NOT NULL, UNIQUE & CHECK
CREATE TABLE products (
id SERIAL PRIMARY KEY,
sku TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
price NUMERIC(10, 2) NOT NULL CHECK (price >= 0)
);
INSERT INTO products (sku, name, price) VALUES ('MOUSE-01', 'Mouse', -5);
-- ERROR: new row violates check constraint "products_price_check"
These aren't application-level validation — they're enforced by Postgres itself, on every insert and update, from every client, forever. Application code should still validate input for a better user experience (a friendly error message beats a raw constraint violation), but the database-level constraint is what actually guarantees the data can never violate the rule, regardless of which application or script writes to it.
2. B-Tree Indexes
Without an index, finding a row means scanning every row in the table — a sequential scan. An index on a column lets Postgres jump directly to matching rows instead, the same way a book's index beats reading every page to find a topic.
CREATE INDEX idx_orders_customer_id ON orders(customer_id);
An index helps a query that filters or joins on that column — but it isn't free:
every index adds overhead to every INSERT/UPDATE/DELETE
(the index itself has to be maintained), and takes disk space. The practical rule:
index columns genuinely used in WHERE, JOIN conditions,
or ORDER BY on large tables — not every column defensively.
3. Reading EXPLAIN ANALYZE
EXPLAIN ANALYZE SELECT * FROM orders WHERE customer_id = 42;
-- Seq Scan on orders (cost=0.00..1834.00 rows=5 width=20) (actual time=0.02..12.4 rows=5 loops=1)
-- Filter: (customer_id = 42)
-- Rows Removed by Filter: 99995
EXPLAIN ANALYZE SELECT * FROM orders WHERE customer_id = 42;
-- Index Scan using idx_orders_customer_id on orders (cost=0.29..8.31 rows=5 width=20) (actual time=0.01..0.02 rows=5 loops=1)
-- Index Cond: (customer_id = 42)
EXPLAIN shows the plan Postgres intends to use; EXPLAIN ANALYZE
actually runs the query and reports real timing alongside it. "Seq Scan," reading
nearly 100,000 rows to find 5, is the tell that a useful index is missing; "Index
Scan" after adding one confirms the fix actually worked — the same
measure-before/measure-after discipline this site's Go and Flutter courses both
teach for their own performance work.
4. Common Performance Pitfalls
- A function applied to the indexed column —
WHERE LOWER(email) = 'asha@example.com'can't use a plain index onemail, since the index stores the original values, not their lowercased form. A functional index (CREATE INDEX ... ON users (LOWER(email))) fixes this specifically. - A leading wildcard in LIKE —
LIKE '%asha%'can't use a standard B-tree index at all, since there's no fixed prefix to jump to;LIKE 'asha%'can. - Indexing a low-cardinality column — an index on a
booleanor a status column with only 3 possible values often doesn't help, since Postgres may reasonably decide a sequential scan is cheaper than the overhead of using the index anyway. - Trusting intuition over EXPLAIN ANALYZE — the only way to know whether an index actually helped, or whether the planner is even using it, is to check.
5. Hands-on Exercise
Diagnose and fix a slow query
Seed a large table, find a real sequential scan, and fix it with an index — proving the fix with EXPLAIN ANALYZE.
Requirements:
- Create an
orderstable (reuse Week 3's schema or similar) and seed it with at least 50,000 rows usinggenerate_series(INSERT INTO orders (customer_id, total) SELECT (random() * 1000)::int, (random() * 500)::numeric(10,2) FROM generate_series(1, 50000)is a good starting point). - Run
EXPLAIN ANALYZEon a query filtering bycustomer_idand confirm it uses a sequential scan. - Add an index on
customer_id, re-run the exact same query, and confirm the plan switches to an index scan — paste both EXPLAIN ANALYZE outputs in a comment. - Add a
CHECKconstraint ensuringtotal >= 0, and confirm attempting to insert a negative total is rejected. - Add a
UNIQUEconstraint somewhere it genuinely belongs (anemailorsku-style column) and confirm a duplicate insert is rejected.
If adding an index doesn't seem to change the query plan at all, the table may simply be small enough that Postgres's planner correctly decides a sequential scan is cheaper than an index scan — this is a real, correct decision, not a bug. Confirm the table genuinely has tens of thousands of rows (not a few dozen) before concluding the index isn't helping.
6. Knowledge Check
Four quick questions. Expand each to check your answer.
Q1
Why is a CHECK constraint a stronger guarantee than validating the same rule only in application code?
Why is a CHECK constraint a stronger guarantee than validating the same rule only in application code?
A CHECK constraint is enforced by Postgres itself on every write, from every source — the application, a script, a database migration, a different application entirely down the line. Application-level validation only protects writes that go through that specific application's code path; anything else that writes directly to the database bypasses it entirely.
Q2
Why does adding an index to every column "just in case" not make a database uniformly faster?
Why does adding an index to every column "just in case" not make a database uniformly faster?
Every index adds real overhead to every insert, update, and delete, since the index itself has to be kept in sync with the table's actual data, plus disk space to store it — indexes that are never actually used by a query still pay this ongoing cost for no benefit. The right approach is indexing columns genuinely used in WHERE, JOIN, or ORDER BY on tables large enough for it to matter, not defensively indexing everything.
Q3
What specifically does "Seq Scan" in an EXPLAIN ANALYZE output indicate, and why is "Rows Removed by Filter: 99995" a strong signal something needs fixing?
What specifically does "Seq Scan" in an EXPLAIN ANALYZE output indicate, and why is "Rows Removed by Filter: 99995" a strong signal something needs fixing?
Seq Scan means Postgres read every row in the table in order, checking each one against the filter condition, rather than jumping directly to matching rows via an index. A count that high for rows removed means the query examined nearly the entire table just to find a handful of matches — exactly the pattern an index on the filtered column would eliminate.
Q4
Why can't a standard B-tree index be used for a query filtering with LIKE '%asha%', even if there's an index on that exact column?
Why can't a standard B-tree index be used for a query filtering with LIKE '%asha%', even if there's an index on that exact column?
A B-tree index is ordered and can only be used efficiently when there's a known starting point to search from — a leading wildcard means there's no fixed prefix Postgres could jump to, since the match could begin anywhere in the string, which forces a full scan of every value regardless of the index's existence. A pattern with a fixed prefix, like LIKE 'asha%', can use the index because it can start from that known prefix.