<< Home

Chapter 8: Indexes and Performance Basics

Indexes speed up reads by providing a smaller, ordered structure that the query planner can search quickly; they trade extra storage and slower writes for faster lookups. This chapter explains physical organization (clustered versus non-clustered using alternative wording), composite index design and maintenance settings, and practical guidance on when to index or leave data unindexed.

šŸ’” Start from the workload: profile slow queries, inspect execution plans, and add the smallest effective index that changes a bad plan into a good one. Measure again after changes.

Clustered vs non-clustered

A clustered organization stores table rows in the same order as a chosen key; a non-clustered index stores a separate structure that points to table rows. Different engines implement this differently.

Engine behaviors at a glance

Engine Clustered storage Notes
MySQL (InnoDB) Yes (primary key defines physical order) The table is organized by the primary key; secondary indexes store primary key values as row locators.
SQL Server Optional clustered index One clustered index per table; non-clustered indexes reference the clustered key or a row identifier.
PostgreSQL No permanent clustering CLUSTER rewrites the table in index order but does not keep it permanently aligned; normal indexes are non-clustered.
SQLite No separate clustered type Rowids define storage order unless using WITHOUT ROWID; indexes are non-clustered.
āš ļø Choosing a primary key in InnoDB affects physical locality. Monotonic keys (for example a synthetic bigint or a timestamp sequence) reduce page splits and fragmentation; random keys (for example UUID v4) may increase write amplification unless mitigated.

Practical implications

-- SQL Server: create a clustered index on (created_at, id)
CREATE CLUSTERED INDEX cix_orders_created_id
  ON dbo.orders (created_at, id);

-- PostgreSQL: opportunistic clustering (one-off rewrite)
CLUSTER orders USING idx_orders_created_at;
-- Later, to refresh the order again:
CLUSTER orders;

-- MySQL (InnoDB): primary key defines clustering
ALTER TABLE orders
  ADD PRIMARY KEY (created_at, id);
šŸ’” If lookups are by a surrogate primary key but most queries filter by a different column (for example customer_id then created_at), keep the surrogate primary key for simplicity and add a composite secondary index for query speed.

Composite indexes, fillfactor, and maintenance

Composite indexes store multiple columns in a single index key. The column order matters; the index can support lookups that match a left-most prefix of the key (and sometimes more with advanced checks by the planner).

Designing composite keys

-- Composite equality then range
CREATE INDEX idx_orders_customer_date
  ON orders (customer_id, order_date);

-- Expression index for case-insensitive search
-- PostgreSQL
CREATE INDEX idx_users_email_lower ON users ((lower(email)));
-- SQLite
CREATE INDEX idx_users_email_lower ON users (lower(email));

-- Covering index with include columns
-- PostgreSQL 11+
CREATE INDEX idx_orders_cov ON orders (customer_id, order_date) INCLUDE (status, total);
-- SQL Server
CREATE INDEX idx_orders_cov ON dbo.orders (customer_id, order_date) INCLUDE (status, total);
āš ļø A composite index on (a, b) helps queries that filter on a or on a and b. It usually does not help queries that filter only on b. Add a second index only if that pattern is common and material.

Fillfactor (page fullness) and fragmentation

Fillfactor controls how full to pack index pages at build time so that future inserts have room to grow. Lower values reduce page splits at the cost of more disk space.

Engine Per-index setting Example
PostgreSQL Yes CREATE INDEX ... WITH (fillfactor = 90)
SQL Server Yes CREATE INDEX ... WITH (FILLFACTOR = 90)
MySQL (InnoDB) Global knob for some operations Engine manages page fill; per-index setting is not typical in common builds
SQLite No Engine manages internal layout automatically
-- PostgreSQL
CREATE INDEX idx_events_created_at
  ON events (created_at)
  WITH (fillfactor = 90);

-- SQL Server
CREATE INDEX idx_events_created_at
  ON dbo.events (created_at)
  WITH (FILLFACTOR = 90, PAD_INDEX = ON);
šŸ’” Use a lower fillfactor for hot, randomly inserted keys (for example UUIDs) to reduce page splits; keep the default for mostly append-only patterns.

Index maintenance and statistics

-- PostgreSQL
REINDEX INDEX idx_users_email;          -- full rebuild
ANALYZE users;                          -- refresh stats
CREATE INDEX CONCURRENTLY idx_e_c ON events (created_at);

-- SQL Server
ALTER INDEX ALL ON dbo.orders REORGANIZE;
ALTER INDEX ALL ON dbo.orders REBUILD WITH (ONLINE = ON);  -- edition dependent
UPDATE STATISTICS dbo.orders WITH FULLSCAN;

-- MySQL
ANALYZE TABLE orders;
OPTIMIZE TABLE orders;                  -- can rebuild table and indexes (engine specific)
āš ļø Maintenance operations can consume I/O and extend transaction log or write-ahead log usage. Schedule them with care and monitor impact on replicas.

When (and when not) to index

Not every column should be indexed. Good candidates are columns used in frequent search predicates, joins, and orderings where the index avoids scanning large portions of the table. Poor candidates either do not filter much or change so often that the write cost outweighs the read benefit.

Good candidates

-- Typical lookup and ordering needs
CREATE INDEX idx_orders_customer_created
  ON orders (customer_id, created_at DESC);

-- Join helper for a foreign key
CREATE INDEX idx_orders_product_id
  ON orders (product_id);
šŸ’” Aim for covering indexes on your top N queries so the engine can satisfy them from index pages alone; use include columns instead of widening the key where available.

Poor candidates

Measuring impact

-- PostgreSQL: show why the planner chooses a path
EXPLAIN (ANALYZE, BUFFERS)
SELECT * FROM orders
WHERE customer_id = $1
ORDER BY created_at DESC
LIMIT 50;

-- SQL Server
SET STATISTICS IO ON;
SET STATISTICS TIME ON;
SELECT TOP (50) *
FROM dbo.orders
WHERE customer_id = @cid
ORDER BY created_at DESC;
āš ļø Indexes that are never used waste memory and I/O. Periodically review usage reports (for example index scans versus table scans where available) and drop dead indexes after verification.

With a clear understanding of physical layout, careful composite design, sensible fillfactor choices, and a policy for measuring benefits, you can keep read performance high without over-indexing or harming write throughput.