🔥 0
0
Lesson 3 of 5 18 min +200 XP

Composite Indexes & Column Order

A composite index covers multiple columns. But here's the catch: column order determines which queries can use it. Get this wrong and your carefully crafted index sits unused.

How Composite Indexes Work

Think of a composite index like a phone book sorted by last name, then first name:

Index on (last_name, first_name):

Adams, Alice     → Row 5
Adams, Bob       → Row 12
Adams, Charlie   → Row 3
Baker, Alice     → Row 8
Baker, David     → Row 1
...

The data is sorted by last_name first, THEN by first_name within each last_name group.

The Leftmost Prefix Rule

A composite index can be used for queries that include a leftmost prefix of its columns.

CREATE INDEX idx_name ON users(last_name, first_name, middle_name);
Query Uses Index? Why
WHERE last_name = 'Smith' Yes Uses first column
WHERE last_name = 'Smith' AND first_name = 'John' Yes Uses first two columns
WHERE last_name = 'Smith' AND first_name = 'John' AND middle_name = 'Q' Yes Uses all three columns
WHERE first_name = 'John' No Skips last_name
WHERE middle_name = 'Q' No Skips first two columns
WHERE first_name = 'John' AND last_name = 'Smith' Yes Order in WHERE doesn't matter!
Key Insight

The order of columns in your WHERE clause doesn't matter - the query optimizer will reorder them. What matters is whether all required prefix columns are present.

Column Order Strategy

The order of columns in a composite index should follow these rules:

Rule 1: Equality Columns First

-- Query pattern
WHERE status = 'active' AND created_at > '2024-01-01'

-- Good: equality first, then range
CREATE INDEX idx_orders ON orders(status, created_at);

-- Bad: range first
CREATE INDEX idx_orders ON orders(created_at, status);

Why? After a range condition, the index can't efficiently use subsequent columns.

INDEX(status, created_at)

1. Jump to status = 'active' section
2. Within that, scan from created_at > '2024-01-01'
Result: Efficient!

INDEX(created_at, status)

1. Scan all rows from created_at > '2024-01-01'
2. For each, check if status = 'active'
Result: Scans too many rows!

Rule 2: High Selectivity Columns First (for equality)

When you have multiple equality conditions, put the most selective column first:

-- country has 195 values, city has 10,000 values
-- Query: WHERE country = 'USA' AND city = 'NYC'

-- Better: more selective first
CREATE INDEX idx_location ON users(city, country);
-- Jumps directly to ~0.01% of data (NYC)

-- Worse: less selective first
CREATE INDEX idx_location ON users(country, city);
-- Jumps to ~5% of data (USA), then filters to NYC

Rule 3: Consider Sort Order

If your query includes ORDER BY, put the sort column last (after equality columns):

-- Query pattern
WHERE user_id = 123 ORDER BY created_at DESC LIMIT 10

-- Optimal: supports both filter and sort
CREATE INDEX idx_user_posts ON posts(user_id, created_at DESC);

-- The index is already sorted, no separate sort needed!

Covering Indexes

A covering index includes all columns needed by a query. The database can answer entirely from the index without touching the table.

-- Query
SELECT user_id, email, created_at
FROM users
WHERE status = 'active'
ORDER BY created_at DESC
LIMIT 100;

-- Covering index
CREATE INDEX idx_users_covering ON users(status, created_at DESC, user_id, email);

Index-Only Scan Visualization

Without covering index:
Index lookup → Get row IDs → Fetch rows from table → Return data
              [Fast]         [Slow - random I/O]

With covering index:
Index lookup → Return data directly from index
              [Fast - all data in index]
Approach Disk Reads Performance
Regular index + table fetch Index + Table pages (random I/O) Slower
Covering index Index pages only (sequential) 2-10x faster

INCLUDE Clause (PostgreSQL 11+)

For covering indexes, use INCLUDE to add non-searchable columns:

-- Instead of putting email in the index key:
CREATE INDEX idx_users ON users(status, created_at, email);

-- Use INCLUDE for columns only needed in SELECT:
CREATE INDEX idx_users ON users(status, created_at) INCLUDE (email, user_id);

Benefits of INCLUDE:

  • Included columns don't affect index ordering
  • Can include columns that aren't indexable (like large TEXT)
  • Slightly more efficient for the query planner

Real-World Example: E-commerce Orders

-- Common query patterns:
-- 1. List orders for a user, recent first
-- 2. Find orders by status for a user
-- 3. Dashboard: all orders by status

-- Table
CREATE TABLE orders (
  id SERIAL PRIMARY KEY,
  user_id INT NOT NULL,
  status VARCHAR(20) NOT NULL,
  total_amount DECIMAL(10,2),
  created_at TIMESTAMP DEFAULT NOW()
);

-- Index for queries 1 & 2
CREATE INDEX idx_orders_user ON orders(user_id, status, created_at DESC)
  INCLUDE (total_amount);

-- Index for query 3 (admin dashboard)
CREATE INDEX idx_orders_status ON orders(status, created_at DESC);
Index Maintenance Cost

Every additional index slows down writes. An orders table might have thousands of inserts per minute. Balance read performance against write overhead.

Common Mistakes

Mistake 1: Index Per Column

-- Don't do this
CREATE INDEX idx1 ON orders(user_id);
CREATE INDEX idx2 ON orders(status);
CREATE INDEX idx3 ON orders(created_at);

-- For: WHERE user_id = 1 AND status = 'active'
-- PostgreSQL might use bitmap AND, but it's slower than:

CREATE INDEX idx_combined ON orders(user_id, status);

Mistake 2: Wrong Column Order

-- Query: WHERE user_id = ? ORDER BY created_at DESC LIMIT 10

-- Wrong order (extra sort needed)
CREATE INDEX idx ON posts(created_at, user_id);

-- Correct order (no sort needed)
CREATE INDEX idx ON posts(user_id, created_at DESC);

Mistake 3: Redundant Indexes

-- These are redundant:
CREATE INDEX idx1 ON users(email);                    -- Covered by idx2
CREATE INDEX idx2 ON users(email, created_at);        -- Keep this one
CREATE INDEX idx3 ON users(email, created_at, name);  -- Maybe keep if needed

-- idx1 is useless - idx2 can handle all queries idx1 would

Key Takeaways

  • Leftmost prefix rule - Queries must use leftmost column(s) to use the index
  • Equality before range - Put = columns before <, >, BETWEEN columns
  • Covering indexes - Include all SELECT columns to avoid table lookups
  • One good composite index - Often better than multiple single-column indexes

Next up: Query Optimization - Using EXPLAIN to see how your indexes are actually being used.

🧠 Quick Quiz

Test your understanding of this lesson.

1

You have an index on (country, city, street). Which query will use the index?

2

For the query 'WHERE status = 'active' AND created_at > '2024-01-01' ORDER BY created_at', what's the optimal index?

3

What is a 'covering index'?