Week 6: Constraints, Indexes & Query Performance

A schema from Week 5 keeps data correct. This week is about keeping it fast — constraints that enforce correctness the database checks automatically, and indexes, which turn a query that scans an entire table into one that jumps straight to the rows that matter, provided you know how to tell the difference.

Module 6 of 10 Week 6 of 10 ~4 Hours Hands-on Exercise Included

By the end of this week, you'll be able to

  • Enforce data rules with NOT NULL, UNIQUE and CHECK constraints
  • Explain what a B-tree index does, and when adding one actually helps
  • Read a query plan with EXPLAIN ANALYZE and identify a sequential scan
  • Recognize common causes of an index silently not being used

1. Constraints: NOT NULL, UNIQUE & CHECK

constraints enforced automatically, on every write
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.

creating an index
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

before an index — a sequential scan
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
after adding the index — an index scan
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 columnWHERE LOWER(email) = 'asha@example.com' can't use a plain index on email, 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 LIKELIKE '%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 boolean or 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

Hands-on

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:

  1. Create an orders table (reuse Week 3's schema or similar) and seed it with at least 50,000 rows using generate_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).
  2. Run EXPLAIN ANALYZE on a query filtering by customer_id and confirm it uses a sequential scan.
  3. 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.
  4. Add a CHECK constraint ensuring total >= 0, and confirm attempting to insert a negative total is rejected.
  5. Add a UNIQUE constraint somewhere it genuinely belongs (an email or sku-style column) and confirm a duplicate insert is rejected.
Hint

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?

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?

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?

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?

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.