| name | apply-index-best-practices |
| description | Comprehensive guide to index best practices in CockroachDB. Covers when to create indexes, naming conventions, composite vs single-column indexes, covering indexes with STORING clause, avoiding redundant indexes, monitoring index usage, and dropping unused indexes. Use when user says "index best practices", "index guidelines", "index strategy", "how to index", or needs comprehensive indexing advice. |
| metadata | {"domain":"SQL","tags":"sql, indexing, best-practices, performance","blooms_level":"Apply","version":"1.0.0","tested_against":"v26.1.0","status":"complete"} |
Apply Index Best Practices
Comprehensive guide to creating and managing indexes effectively in CockroachDB. Covers when to create indexes, naming conventions, types of indexes, and ongoing maintenance.
What This Skill Teaches
You will learn:
- When (and when not) to create indexes
- Index naming conventions
- Choosing between single-column and composite indexes
- Using covering indexes with STORING clause
- Identifying and avoiding redundant indexes
- Monitoring index usage
- Dropping unused indexes safely
When to Create Indexes
Rule 1: Index Columns Used in WHERE Clauses
Create index when:
- Query filters on specific columns frequently
- Selectivity is good (< 20% of rows returned)
SELECT * FROM orders WHERE status = 'pending';
CREATE INDEX idx_orders_status ON orders(status);
Don't index when:
- Column has very few distinct values (low cardinality)
- Query returns > 50% of rows (full scan faster)
CREATE INDEX idx_users_status ON users(status);
Rule 2: Index Columns Used in ORDER BY
Create index when:
- Query sorts frequently on specific columns
- Combined with WHERE clause for same query
SELECT * FROM events
WHERE user_id = 'user-123'
ORDER BY created_at DESC
LIMIT 10;
CREATE INDEX idx_events_user_time ON events(user_id, created_at DESC);
Rule 3: Index JOIN Columns
Create index on foreign key columns:
CREATE TABLE orders (
id UUID PRIMARY KEY,
customer_id UUID,
total DECIMAL
);
CREATE TABLE customers (
id UUID PRIMARY KEY,
name STRING
);
SELECT o.*, c.name
FROM orders o
JOIN customers c ON o.customer_id = c.id;
CREATE INDEX idx_orders_customer ON orders(customer_id);
Rule 4: Don't Index Low-Cardinality Columns Alone
Bad - boolean or enum with few values:
CREATE INDEX idx_users_active ON users(is_active);
CREATE INDEX idx_requests_status ON requests(status);
Better - use composite index or don't index:
CREATE INDEX idx_users_active_created ON users(is_active, created_at);
CREATE INDEX idx_users_active ON users(id) WHERE is_active = true;
Index Naming Conventions
Standard format: idx_<table>_<columns>_[suffix]
Single-Column Index
CREATE INDEX idx_users_email ON users(email);
CREATE INDEX idx_orders_status ON orders(status);
CREATE INDEX idx_products_sku ON products(sku);
Composite Index
CREATE INDEX idx_orders_customer_status ON orders(customer_id, status);
CREATE INDEX idx_events_user_type_time ON events(user_id, event_type, created_at);
Covering Index
CREATE INDEX idx_users_email_covering ON users(email) STORING (name, created_at);
CREATE INDEX idx_orders_customer_inc ON orders(customer_id) STORING (total, status);
Partial Index
CREATE INDEX idx_orders_pending ON orders(created_at) WHERE status = 'pending';
CREATE INDEX idx_users_active ON users(email) WHERE is_active = true;
Descending Index
CREATE INDEX idx_events_time_desc ON events(created_at DESC);
CREATE INDEX idx_orders_user_date_desc ON orders(user_id, created_at DESC);
Key principles:
- Use lowercase with underscores
- Start with
idx_
- Include table name
- Include indexed columns (abbreviated if needed)
- Add suffix for special types (covering, partial, desc)
- Keep it under 63 characters (PostgreSQL limit)
Composite vs Single-Column Indexes
Use Single-Column Index When
Pattern: Query filters on one column only.
SELECT * FROM users WHERE email = 'alice@example.com';
CREATE INDEX idx_users_email ON users(email);
Use Composite Index When
Pattern 1: Query filters on multiple columns.
SELECT * FROM products
WHERE category = 'electronics' AND status = 'active';
CREATE INDEX idx_products_cat_status ON products(category, status);
Pattern 2: Query filters on one column and sorts on another.
SELECT * FROM orders
WHERE customer_id = 'cust-123'
ORDER BY created_at DESC
LIMIT 10;
CREATE INDEX idx_orders_customer_date ON orders(customer_id, created_at DESC);
Pattern 3: Query has WHERE + ORDER BY on different columns.
SELECT * FROM events
WHERE user_id = 'user-123'
ORDER BY event_time DESC;
CREATE INDEX idx_events_user_time ON events(user_id, event_time DESC);
Column Ordering in Composite Indexes
Rule: Most selective column first, then by query pattern.
Example 1: Filter then sort.
CREATE INDEX idx_orders_user_date ON orders(user_id, created_at DESC);
Example 2: Multiple filters.
CREATE INDEX idx_orders_customer_status ON orders(customer_id, status);
Example 3: Range queries.
CREATE INDEX idx_products_cat_price ON products(category, price);
Covering Indexes with STORING Clause
Purpose: Avoid index-join by storing additional columns in index.
When to Use STORING
Use when:
- Query always selects same columns
- Query executed frequently (high QPS)
- EXPLAIN shows "index join"
- Stored columns are small
SELECT id, name, email FROM users WHERE status = 'active';
CREATE INDEX idx_users_status ON users(status);
CREATE INDEX idx_users_status_covering
ON users(status)
STORING (name, email);
What to Store
Store columns that are:
- Frequently selected together
- Small (strings, numbers, timestamps - not large TEXT/JSONB)
- Relatively stable (don't change often)
CREATE INDEX idx_orders_customer_details
ON orders(customer_id)
STORING (total, status, created_at);
CREATE INDEX idx_products_sku
ON products(sku)
STORING (description, specifications, reviews);
STORING Trade-offs
Benefits:
- Eliminate index joins (2-5x faster queries)
- Consistent performance
- Lower CPU usage
Costs:
- Larger index size (+50-200% depending on stored columns)
- Slower writes (more data to update)
- More disk space
Avoiding Redundant Indexes
Redundant index: Index that provides no benefit because another index covers the same queries.
Pattern 1: Subset Indexes
Redundant - second index is prefix of first:
CREATE INDEX idx_orders_customer_status_date
ON orders(customer_id, status, created_at);
CREATE INDEX idx_orders_customer ON orders(customer_id);
CREATE INDEX idx_orders_customer_status ON orders(customer_id, status);
Rule: Index on (A, B, C) makes indexes on (A) and (A, B) redundant.
Exception: Sometimes smaller index is faster for specific queries.
Pattern 2: Different Column Order
Not redundant - different column order = different use case:
CREATE INDEX idx_orders_customer_date ON orders(customer_id, created_at);
CREATE INDEX idx_orders_date_customer ON orders(created_at, customer_id);
Pattern 3: Overlapping Covering Indexes
Redundant - same indexed columns, different STORING:
CREATE INDEX idx1 ON users(email) STORING (name);
CREATE INDEX idx2 ON users(email) STORING (name, created_at);
CREATE INDEX idx_users_email_details
ON users(email)
STORING (name, created_at);
Finding Redundant Indexes
Check index definitions:
SHOW INDEXES FROM orders;
Monitoring Index Usage
Method 1: Index Usage Statistics
SELECT
index_name,
total_reads,
last_read
FROM crdb_internal.index_usage_statistics
WHERE table_name = 'orders'
ORDER BY total_reads DESC;
Indicators:
total_reads = 0 - Unused index, candidate for removal
last_read IS NULL - Never used
- Low total_reads - Rarely used, evaluate if needed
Method 2: DB Console Index Recommendations
Steps:
- Navigate to DB Console → Insights → Schema Insights
- Look for "Unused Index" recommendations
- Review suggested indexes to drop
Method 3: EXPLAIN Analysis
Test if index is used:
EXPLAIN SELECT * FROM orders WHERE customer_id = 'cust-123';
Method 4: Statement Statistics
SELECT
metadata->>'query' as query,
statistics->>'cnt' as execution_count,
statistics->>'mean_exec_time' as avg_time
FROM crdb_internal.statement_statistics
WHERE metadata->>'query' LIKE '%orders%'
AND statistics->>'scan_type' = 'full'
ORDER BY (statistics->>'cnt')::INT DESC;
Dropping Unused Indexes
Before Dropping: Verify Index is Unused
Check 1: Zero reads in last 30 days.
SELECT
index_name,
total_reads,
last_read
FROM crdb_internal.index_usage_statistics
WHERE table_name = 'orders'
AND (total_reads = 0 OR last_read < now() - INTERVAL '30 days');
Check 2: No queries in statement stats use the index.
EXPLAIN SELECT * FROM orders WHERE status = 'pending';
Check 3: Review index definition.
SHOW CREATE TABLE orders;
Dropping an Index
DROP INDEX idx_orders_status;
DROP INDEX idx_orders_old1, idx_orders_old2;
DROP INDEX idx_orders_status CASCADE;
Important: Index drops are non-blocking in CockroachDB (v22.1+).
Monitoring After Drop
SELECT
metadata->>'query' as query,
statistics->>'mean_exec_time' as avg_time,
statistics->>'cnt' as executions
FROM crdb_internal.statement_statistics
WHERE metadata->>'query' LIKE '%orders%'
ORDER BY (statistics->>'mean_exec_time')::FLOAT DESC
LIMIT 10;
Watch for: Sudden increase in query time after index drop.
Best Practices Checklist
Index Creation
- ✅ Create indexes on columns in WHERE clauses (high selectivity)
- ✅ Create indexes on columns in ORDER BY clauses
- ✅ Create indexes on foreign key columns (JOIN columns)
- ✅ Use composite indexes for multi-column filters
- ✅ Use covering indexes (STORING) for frequently-selected columns
- ✅ Follow naming conventions:
idx_<table>_<cols>
- ❌ Don't index low-cardinality columns alone
- ❌ Don't create redundant indexes
Index Maintenance
- ✅ Monitor index usage regularly (monthly)
- ✅ Drop unused indexes (zero reads for 30+ days)
- ✅ Review index size vs table size
- ✅ Check for redundant indexes after schema changes
- ✅ Use DB Console insights for recommendations
- ❌ Don't keep "just in case" indexes
- ❌ Don't create indexes without measuring query performance
Index Limits
- ✅ Aim for < 10 indexes per table (general guideline)
- ✅ Larger tables can have more indexes (if needed)
- ❌ Avoid > 20 indexes per table (write performance suffers)
- ❌ Avoid very large covering indexes (> 1GB per index)
Common Scenarios
Scenario 1: E-Commerce Orders Table
CREATE TABLE orders (
id UUID PRIMARY KEY,
customer_id UUID,
status STRING,
total DECIMAL,
created_at TIMESTAMPTZ,
updated_at TIMESTAMPTZ
);
SELECT * FROM orders WHERE customer_id = ? ORDER BY created_at DESC;
SELECT * FROM orders WHERE status = 'pending' ORDER BY created_at;
SELECT id, customer_id, total, status FROM orders ORDER BY created_at DESC LIMIT 50;
CREATE INDEX idx_orders_customer_date
ON orders(customer_id, created_at DESC);
CREATE INDEX idx_orders_status_date
ON orders(status, created_at)
STORING (customer_id, total);
CREATE INDEX idx_orders_date_desc
ON orders(created_at DESC)
STORING (customer_id, total, status);
Scenario 2: User Lookup Table
CREATE TABLE users (
id UUID PRIMARY KEY,
email STRING UNIQUE,
username STRING,
name STRING,
status STRING,
created_at TIMESTAMPTZ
);
SELECT id, name, status FROM users WHERE email = ?;
SELECT id, name FROM users WHERE username = ?;
SELECT id, email, name FROM users WHERE status = 'active' ORDER BY created_at DESC;
CREATE INDEX idx_users_username
ON users(username)
STORING (name);
CREATE INDEX idx_users_status_created
ON users(status, created_at DESC)
STORING (email, name);
Scenario 3: Event Log Table
CREATE TABLE events (
id UUID PRIMARY KEY,
user_id UUID,
event_type STRING,
data JSONB,
created_at TIMESTAMPTZ
);
SELECT * FROM events WHERE user_id = ? ORDER BY created_at DESC LIMIT 100;
SELECT event_type, count(*) FROM events WHERE created_at > ? GROUP BY event_type;
CREATE INDEX idx_events_user_time
ON events(user_id, created_at DESC);
CREATE INDEX idx_events_time_type
ON events(created_at DESC, event_type);
Common Mistakes to Avoid
Mistake 1: Over-Indexing
Problem: Creating index for every column.
CREATE INDEX idx_users_email ON users(email);
CREATE INDEX idx_users_username ON users(username);
CREATE INDEX idx_users_name ON users(name);
CREATE INDEX idx_users_status ON users(status);
CREATE INDEX idx_users_created ON users(created_at);
Solution: Only index columns actually used in WHERE/ORDER BY.
Mistake 2: Wrong Column Order
Problem: Composite index with wrong column order.
CREATE INDEX idx_orders_date_customer ON orders(created_at, customer_id);
CREATE INDEX idx_orders_customer_date ON orders(customer_id, created_at DESC);
Mistake 3: Creating Subset Indexes
Problem: Creating smaller indexes when composite exists.
CREATE INDEX idx_orders_customer_status_date
ON orders(customer_id, status, created_at);
CREATE INDEX idx_orders_customer ON orders(customer_id);
CREATE INDEX idx_orders_customer_status ON orders(customer_id, status);
Mistake 4: Not Using Covering Indexes
Problem: Query has index-join when it could use covering index.
SELECT id, name FROM users WHERE email = ?;
CREATE INDEX idx_users_email ON users(email);
CREATE INDEX idx_users_email_name ON users(email) STORING (name);
Mistake 5: Keeping Unused Indexes
Problem: Indexes created months ago, never used.
CREATE INDEX idx_orders_old_status ON orders(old_status_column);
DROP INDEX idx_orders_old_status;
Related Skills
create-secondary-indexes-on-single-columns - Creating basic indexes
create-composite-indexes-for-multi-column-queries - Multi-column indexes
create-covering-indexes-with-storing-clause - Covering indexes
create-partial-indexes-with-where-clauses - Partial indexes
optimize-composite-index-column-ordering - Column ordering strategies
avoid-over-indexing-tables - Managing index count
monitor-index-usage-statistics - Tracking index usage
understand-mvcc-impact-on-indexes - MVCC effects on indexes
Documentation