Fatskills
Practice. Master. Repeat.
Study Guide: TECH **SQL Indexing Strategies for Data Analysis: Zero-Fluff, Hands-On Guide**
Source: https://www.fatskills.com/data-science/chapter/tech-sql-indexing-strategies-for-data-analysis-zero-fluff-hands-on-guide

TECH **SQL Indexing Strategies for Data Analysis: Zero-Fluff, Hands-On Guide**

By Fatskills Exam Guides Team — the exam nerds behind 28,500+ quizzes and 2.1M practice questions across 500+ global exams.

⏱️ ~10 min read

SQL Indexing Strategies for Data Analysis: Zero-Fluff, Hands-On Guide

(B-tree, Bitmap, Covering Index, Partial Index)


1. What This Is & Why It Matters

You’re analyzing a 500M-row sales table in PostgreSQL. A simple WHERE customer_id = 12345 query takes 12 seconds—unacceptable for a dashboard. Your boss asks, "Why is this slow? Can we fix it without rewriting the app?"

This is where indexing strategies come in.
Indexes are pre-sorted lookup structures that let the database skip scanning the entire table. Think of them like a book’s index: instead of reading every page to find "SQL tuning," you flip to the index, see "page 42," and jump straight there.

Why this matters in production:
- Performance: A missing or poorly chosen index can turn a 100ms query into a 10-second disaster.
- Cost: Slow queries burn CPU, memory, and cloud bills (e.g., AWS RDS charges by the hour).
- Scalability: A table with 1M rows today might have 100M tomorrow. Indexes keep queries fast as data grows.
- Legacy systems: You’ll inherit databases with no indexes, or worse—too many indexes (which slow down writes).

Real-world scenario:
You’re optimizing a reporting system for an e-commerce company. The orders table has 200M rows, and analysts run queries like:


SELECT customer_id, SUM(amount)
FROM orders
WHERE order_date BETWEEN '2023-01-01' AND '2023-12-31'
  AND status = 'completed'
GROUP BY customer_id;

Without the right indexes, this query could take minutes. With the right ones, it finishes in <100ms.


2. Core Concepts & Components


? B-tree Index

  • Definition: The default index type in PostgreSQL, MySQL, and SQL Server. Balanced tree structure that keeps data sorted for fast lookups, inserts, and deletes.
  • Production insight: B-trees are versatile—great for =, >, <, BETWEEN, and LIKE 'prefix%' queries. But they don’t work well for LIKE '%suffix' (no leading wildcard).
  • Example use case: Indexing a customer_id column for fast WHERE customer_id = 123 lookups.

? Bitmap Index

  • Definition: Stores a bitmap (array of 0s and 1s) for each distinct value in a column. Optimized for low-cardinality columns (few unique values, e.g., gender, status).
  • Production insight: Never use for high-cardinality columns (e.g., user_id). Bitmap indexes explode in size and slow down writes. Oracle and SQL Server support them; PostgreSQL does not (use BRIN or GIN instead).
  • Example use case: Indexing a status column with values ['pending', 'completed', 'cancelled'].

? Covering Index

  • Definition: An index that includes all columns needed for a query, so the database never has to read the actual table (avoids "table lookups").
  • Production insight: Reduces I/O by 90%+ for read-heavy workloads. But it increases index size and slows down writes.
  • Example use case:
    ```sql -- Query: SELECT customer_id, order_date FROM orders WHERE customer_id = 123;

-- Covering index: CREATE INDEX idx_orders_covering ON orders(customer_id) INCLUDE (order_date); ```

? Partial Index

  • Definition: An index that only includes rows matching a condition (e.g., WHERE status = 'active'). Smaller and faster than a full index.
  • Production insight: Saves storage and speeds up queries on filtered subsets. Great for tables where most queries target a small fraction of rows (e.g., WHERE is_active = true).
  • Example use case:
    sql -- Only index "active" users: CREATE INDEX idx_active_users ON users(email) WHERE is_active = true;

? Composite Index (Multi-column Index)

  • Definition: An index on multiple columns (e.g., (last_name, first_name)). Order matters!
  • Production insight: Leftmost prefix rule: A composite index on (A, B, C) can be used for queries on (A), (A, B), or (A, B, C)—but not (B, C) alone.
  • Example use case:
    sql -- Good for: WHERE last_name = 'Smith' AND first_name = 'John' CREATE INDEX idx_name ON users(last_name, first_name);

? BRIN Index (Block Range Index)

  • Definition: PostgreSQL-specific index that groups data by physical blocks (e.g., 128MB chunks). Best for large, naturally ordered data (e.g., timestamps).
  • Production insight: 10x smaller than B-tree for time-series data, but slower for random lookups. Use when you can’t afford a B-tree (e.g., 10B-row table).
  • Example use case:
    sql -- Index a timestamp column in a 500M-row table: CREATE INDEX idx_orders_brin ON orders USING BRIN(order_date);

? GIN Index (Generalized Inverted Index)

  • Definition: Optimized for complex data types (arrays, JSON, full-text search). Breaks values into "tokens" for fast lookups.
  • Production insight: Slower to build than B-tree, but essential for JSON/array queries. Use when you need WHERE tags @> ARRAY['urgent'] or WHERE json_data->>'key' = 'value'.
  • Example use case:
    sql -- Index a JSON column: CREATE INDEX idx_orders_gin ON orders USING GIN(json_metadata);


3. Step-by-Step Hands-On: Optimizing a Slow Query


Prerequisites

  • PostgreSQL 12+ (or MySQL 8.0+, SQL Server 2019+).
  • A table with at least 1M rows (use Mockaroo or generate data with generate_series).
  • Basic SQL knowledge (SELECT, EXPLAIN ANALYZE).

Scenario

You have a sales table with 10M rows:


CREATE TABLE sales (
id SERIAL PRIMARY KEY,
customer_id INT NOT NULL,
product_id INT NOT NULL,
sale_date TIMESTAMP NOT NULL,
amount DECIMAL(10, 2) NOT NULL,
status VARCHAR(20) NOT NULL -- 'pending', 'completed', 'cancelled' );

A query is slow:


-- Takes 8 seconds on 10M rows
EXPLAIN ANALYZE
SELECT customer_id, SUM(amount)
FROM sales
WHERE sale_date BETWEEN '2023-01-01' AND '2023-12-31'
  AND status = 'completed'
GROUP BY customer_id;

Step 1: Diagnose the Problem

Run EXPLAIN ANALYZE to see the query plan:


EXPLAIN ANALYZE
SELECT customer_id, SUM(amount)
FROM sales
WHERE sale_date BETWEEN '2023-01-01' AND '2023-12-31'
  AND status = 'completed'
GROUP BY customer_id;

Output (simplified):


Seq Scan on sales  (cost=0.00..350000.00 rows=1000000 width=12) (actual time=0.123..8123.456 rows=123456 loops=1)
  Filter: ((sale_date >= '2023-01-01'::timestamp) AND (sale_date <= '2023-12-31'::timestamp) AND (status = 'completed'::text))
  Rows Removed by Filter: 9876543

Key takeaways:
- Seq Scan (full table scan): The database reads every row (10M!) because there’s no index.
- Rows Removed by Filter: 9.8M rows were discarded after scanning—wasteful.

Step 2: Add a Composite Index

Create an index on (sale_date, status) to cover the WHERE clause:


CREATE INDEX idx_sales_date_status ON sales(sale_date, status);

Why this order?
- sale_date is range-filtered (BETWEEN), so it goes first.
- status is equality-filtered (=), so it goes second.

Step 3: Verify the Index is Used

Run EXPLAIN ANALYZE again:


EXPLAIN ANALYZE
SELECT customer_id, SUM(amount)
FROM sales
WHERE sale_date BETWEEN '2023-01-01' AND '2023-12-31'
  AND status = 'completed'
GROUP BY customer_id;

Output (simplified):


Index Scan using idx_sales_date_status on sales  (cost=0.42..12345.67 rows=123456 width=12) (actual time=0.045..123.456 rows=123456 loops=1)
  Index Cond: ((sale_date >= '2023-01-01'::timestamp) AND (sale_date <= '2023-12-31'::timestamp) AND (status = 'completed'::text))

Key improvements:
- Index Scan: Now uses the index (no full table scan).
- Time: Dropped from 8s → 123ms (65x faster!).

Step 4: Make It a Covering Index

The query still needs to read the table to get customer_id and amount. Add them to the index:


CREATE INDEX idx_sales_covering ON sales(sale_date, status) INCLUDE (customer_id, amount);

Verify:


EXPLAIN ANALYZE
SELECT customer_id, SUM(amount)
FROM sales
WHERE sale_date BETWEEN '2023-01-01' AND '2023-12-31'
  AND status = 'completed'
GROUP BY customer_id;

Output:


Index Only Scan using idx_sales_covering on sales  (cost=0.42..12345.67 rows=123456 width=12) (actual time=0.034..87.654 rows=123456 loops=1)

Key improvements:
- Index Only Scan: Never touches the table (all data comes from the index).
- Time: Dropped from 123ms → 87ms (30% faster).

Step 5: Add a Partial Index (Optional)

If most queries filter on status = 'completed', create a partial index:


CREATE INDEX idx_sales_completed ON sales(sale_date) INCLUDE (customer_id, amount)
WHERE status = 'completed';

Verify:


EXPLAIN ANALYZE
SELECT customer_id, SUM(amount)
FROM sales
WHERE sale_date BETWEEN '2023-01-01' AND '2023-12-31'
  AND status = 'completed'
GROUP BY customer_id;

Output:


Index Only Scan using idx_sales_completed on sales  (cost=0.28..8765.43 rows=123456 width=12) (actual time=0.023..65.432 rows=123456 loops=1)

Key improvements:
- Smaller index: Only includes status = 'completed' rows.
- Time: Dropped from 87ms → 65ms (25% faster).


4. ? Production-Ready Best Practices


Performance

  • Index columns used in WHERE, JOIN, ORDER BY, and GROUP BY.
  • Avoid indexing columns with low cardinality (e.g., gender, is_active). Use bitmap indexes (Oracle/SQL Server) or BRIN (PostgreSQL) instead.
  • Order matters in composite indexes: Put equality filters first, then range filters.
  • ❌ Bad: (status, sale_date) for WHERE sale_date BETWEEN ... AND status = 'completed'
  • ✅ Good: (status, sale_date) for WHERE status = 'completed' AND sale_date BETWEEN ...
  • Use covering indexes for read-heavy workloads (e.g., reporting). But avoid over-indexing—each index slows down INSERT/UPDATE/DELETE.

Cost Optimization

  • Drop unused indexes: Run pg_stat_user_indexes (PostgreSQL) or sys.dm_db_index_usage_stats (SQL Server) to find unused indexes.
  • Use partial indexes for large tables where most queries filter on a subset (e.g., WHERE is_active = true).
  • Consider BRIN indexes for time-series data (e.g., logs, IoT) to save space.

Reliability & Maintainability

  • Name indexes consistently: Use idx_<table>_<column(s)> (e.g., idx_orders_customer_id).
  • Document why an index exists: Add a comment: sql COMMENT ON INDEX idx_sales_covering IS 'Covers 90% of reporting queries. Do not drop without checking dashboard performance.';
  • Test index changes in staging: Use pg_repack (PostgreSQL) or ONLINE INDEX REBUILD (SQL Server) to avoid locking tables in production.

Observability

  • Monitor index usage: PostgreSQL: sql SELECT schemaname, relname, indexrelname, idx_scan FROM pg_stat_user_indexes ORDER BY idx_scan ASC; SQL Server: sql SELECT OBJECT_NAME(i.object_id) AS table_name,
    i.name AS index_name,
    ius.user_seeks + ius.user_scans + ius.user_lookups AS reads,
    ius.user_updates AS writes FROM sys.indexes i JOIN sys.dm_db_index_usage_stats ius ON i.object_id = ius.object_id AND i.index_id = ius.index_id ORDER BY reads ASC;
  • Set up alerts for:
  • High seq_scan (full table scans).
  • Unused indexes (writes without reads).
  • Long-running queries (pg_stat_activity in PostgreSQL).


5. ⚠️ Common Mistakes & Traps

Mistake Symptom Fix/Prevention
Indexing every column Slow writes, high storage usage. Only index columns used in WHERE, JOIN, or ORDER BY. Use EXPLAIN to verify.
Wrong composite index order Index not used for queries. Put equality filters first, then range filters. Test with EXPLAIN.
Using B-tree for low-cardinality Index is huge and slow. Use bitmap index (Oracle/SQL Server) or BRIN (PostgreSQL) instead.
No covering index Queries do "table lookups" after index scan. Add INCLUDE columns to avoid reading the table.
Ignoring partial indexes Indexes are bloated with unused rows. Use WHERE to filter rows in the index (e.g., WHERE status = 'active').
Not monitoring index usage Unused indexes slow down writes. Run pg_stat_user_indexes (PostgreSQL) or sys.dm_db_index_usage_stats (SQL Server) weekly.


6. ? Exam/Certification Focus


Typical Question Patterns

  1. "Which index type is best for a status column with 3 possible values?"
  2. Bitmap index (low cardinality).
  3. ❌ B-tree (wastes space).

  4. "You have a query WHERE last_name = 'Smith' AND first_name = 'John'. Which composite index is best?"

  5. (last_name, first_name) (leftmost prefix rule).
  6. (first_name, last_name) (won’t be used for last_name alone).

  7. "A query uses an index but is still slow. What’s likely missing?"

  8. Covering index (avoids table lookups).
  9. ❌ More RAM (not the root cause).

  10. "When should you use a partial index?"

  11. ✅ When most queries filter on a subset (e.g., WHERE is_active = true).
  12. ❌ For high-cardinality columns (e.g., user_id).

Key Trap Distinctions

Concept Trap Correct Approach
B-tree vs. Bitmap Using B-tree for low-cardinality columns (e.g., gender). Use bitmap index (Oracle/SQL Server) or BRIN (PostgreSQL).
Composite Index Order Indexing (first_name, last_name) for WHERE last_name = 'Smith'. Put equality filters first ((last_name, first_name)).
Covering Index Forgetting to include columns in SELECT or GROUP BY. Add all needed columns with INCLUDE.
Partial Index Creating a partial index for a column with high cardinality (e.g., id). Only use for low-cardinality subsets (e.g., WHERE status = 'active').

Scenario-Based Question

"You have a 1B-row table with a timestamp column. Most queries filter on timestamp and status. The table is write-heavy. What’s the best indexing strategy?"
- ✅ Composite index on (timestamp, status) (covers most queries).
- ✅ Partial index on timestamp WHERE status = 'active' (if most queries filter on status = 'active').
- ❌ Bitmap index on status (PostgreSQL doesn’t support it; use BRIN instead).
- ❌ Index on timestamp alone (won’t help with status filters).


7. ? Hands-On Challenge


Challenge

You have a users table:


CREATE TABLE users (
id SERIAL PRIMARY KEY,
email VARCHAR(255) NOT NULL,
is_active BOOLEAN NOT NULL DEFAULT true,
last_login TIMESTAMP,
signup_date DATE NOT NULL );

Query 1: SELECT email FROM users WHERE is_active = true AND signup_date > '2023-01-01'; Query 2: `SELECT email, last_login FROM users



ADVERTISEMENT