Let’s look into some of the features of sql data indexing. This will be super helpful while developing our Rails 8 Application.
💎 Part 1: What is a Covering Index?
Normally when you query:
SELECT * FROM users WHERE username = 'bob';
Database searches username index (secondary).
Finds a pointer (TID or PK).
Then fetches full row from table (heap or clustered B-tree).
Problem:
Heap fetch = extra disk read.
Clustered B-tree fetch = extra traversal.
📜 Covering Index idea:
✅ If the index already contains all the columns you need, ✅ Then the database does not need to fetch the full row!
It can answer the query purely by scanning the index! ⚡
Boom — one disk read, no extra hop!
✏️ Example in PostgreSQL:
Suppose your query is:
SELECT username FROM users WHERE username = 'bob';
You only need username.
But by default, PostgreSQL indexes only store the index column (here, username) + TID.
✅ So in this case — already covering!
No heap fetch needed!
✏️ Example in MySQL InnoDB:
Suppose your query is:
SELECT username FROM users WHERE username = 'bob';
Secondary index (username) contains:
username (indexed column)
user_id (because secondary indexes in InnoDB always store PK)
♦️ So again, already covering! No need to jump to the clustered index!
🎯 Key point:
If your query only asks for columns already inside the index, then only the index is touched ➔ no second lookup ➔ super fast!
💎 Part 2: Real SQL Examples
✨ PostgreSQL
Create a covering index for common query:
CREATE INDEX idx_users_username_email ON users (username, email);
Now if you run:
SELECT email FROM users WHERE username = 'bob';
Postgres can:
Search index on username
Already have email in index
✅ No heap fetch!
(And Postgres is smart: it checks index-only scan automatically.)
✨ MySQL InnoDB
Create a covering index:
CREATE INDEX idx_users_username_email ON users (username, email);
✅ Now query:
SELECT email FROM users WHERE username = 'bob';
Same behavior:
Only secondary index read.
No need to touch primary clustered B-tree.
💎 Part 3: Tips to design smart Covering Indexes
✅ If your query uses WHERE on col1 and SELECTcol2, ✅ Best to create index: (col1, col2).
✅ Keep indexes small — don’t add 10 columns unless needed. ✅ Avoid huge TEXT or BLOB columns in covering indexes — they make indexes heavy.
✅ Composite indexes are powerful:
CREATE INDEX idx_users_username_email ON users (username, email);
→ Can be used for:
WHERE username = ?
WHERE username = ? AND email = ?
etc.
✅ Monitor index usage:
PostgreSQL: EXPLAIN ANALYZE
MySQL: EXPLAIN
✅ Always check if Index Only Scan or Using Index appears in EXPLAIN plan!
📚 Quick Summary Table
Database
Normal Query
With Covering Index
PostgreSQL
B-tree ➔ Heap fetch (unless TID optimization)
B-tree scan only
MySQL InnoDB
Secondary B-tree ➔ Primary B-tree
Secondary B-tree only
Result
2 steps
1 step
Speed
Slower
Faster
🏆 Great! — Now We Know:
🧊 How heap fetch works! 🧊 How block lookup is O(1)! 🧊 How covering indexes skip heap fetch! 🧊 How to create super fast indexes for PostgreSQL and MySQL!
🦾 Advanced Indexing Tricks (Real Production Tips)
Now it’s time to look into super heavy functionalities that Postgres supports for making our sql data search/fetch super fast and efficient.
1. 🎯 Partial Indexes (PostgreSQL ONLY)
✅ Instead of indexing the whole table, ✅ You can index only the rows you care about!
Example:
Suppose 95% of users have status = 'inactive', but you only search active users:
SELECT * FROM users WHERE status = 'active' AND email = 'bob@example.com';
👉 Instead of indexing the whole table:
CREATE INDEX idx_active_users_email ON users (email) WHERE status = 'active';
♦️ PostgreSQL will only store rows with status = 'active' in this index!
Advantages:
Smaller index = Faster scans
Less space on disk
Faster index maintenance (less updates/inserts)
Important:
MySQL (InnoDB) does NOT support partial indexes 😔 — only PostgreSQL has this superpower.
2. 🎯 INCLUDE Indexes (PostgreSQL 11+)
✅ Normally, a composite index uses all columns for sorting/searching. ✅ With INCLUDE, extra columns are just stored in index, not used for ordering.
Example:
CREATE INDEX idx_username_include_email ON users (username) INCLUDE (email);
Meaning:
username is indexed and ordered.
email is only stored alongside.
Now query:
SELECT email FROM users WHERE username = 'bob';
➔ Index-only scan — no heap fetch.
Advantages:
Smaller & faster than normal composite indexes.
Helps to create very efficient covering indexes.
Important:
MySQL 8.0 added something similar with INVISIBLE columns but it’s still different.
3. 🎯 Composite Index Optimization
✅ Always order columns inside index smartly based on query pattern.
Golden Rules:
⚜️ Equality columns first (WHERE col = ?) ⚜️ Range columns second (WHERE col BETWEEN ?) ⚜️ SELECT columns last (for covering)
Example:
If query is:
SELECT email FROM users WHERE status = 'active' AND created_at > '2024-01-01';
Best index:
CREATE INDEX idx_users_status_created_at ON users (status, created_at) INCLUDE (email);
♦️ status first (equality match) ♦️ created_at second (range) ♦️ email included (covering)
Bad Index: (wrong order)
CREATE INDEX idx_created_at_status ON users (created_at, status);
→ Will not be efficient!
4. 🎯 BRIN Indexes (PostgreSQL ONLY, super special!)
✅ When your table is very huge (millions/billions of rows), ✅ And rows are naturally ordered (like timestamp, id increasing), ✅ You can create a BRIN (Block Range Index).
Example:
CREATE INDEX idx_users_created_at_brin ON users USING BRIN (created_at);
♦️ BRIN stores summaries of large ranges of pages (e.g., min/max timestamp per 128 pages).
♦️ Ultra small index size.
♦️ Very fast for large range queries like:
SELECT * FROM users WHERE created_at BETWEEN '2024-01-01' AND '2024-04-01';
Important:
BRIN ≠ B-tree
BRIN is approximate, B-tree is precise.
Only useful if data is naturally correlated with physical storage order.
MySQL?
MySQL does not have BRIN natively. PostgreSQL has a big advantage here.
5. 🎯 Hash Indexes (special case)
✅ If your query is always exact equality (not range), ✅ You can use hash indexes.
Example:
CREATE INDEX idx_users_username_hash ON users USING HASH (username);
Useful for:
Simple WHERE username = 'bob'
Never ranges (BETWEEN, LIKE, etc.)
⚠️ Warning:
Hash indexes used to be “lossy” before Postgres 10.
Now they are safe, but usually B-tree is still better unless you have very heavy point lookups.
😎 PRO-TIP: Which Index Type to Use?
Use case
Index type
Search small ranges or equality
B-tree
Search on huge tables with natural order (timestamps, IDs)
BRIN
Only exact match, super heavy lookup
Hash
Search only small part of table (active users, special conditions)
Partial index
Need to skip heap fetch
INCLUDE / Covering Index
🗺️ Quick Visual Mindmap:
Your Query
│
├── Need Equality + Range? ➔ B-tree
│
├── Need Huge Time Range Query? ➔ BRIN
│
├── Exact equality only? ➔ Hash
│
├── Want Smaller Index (filtered)? ➔ Partial Index
│
├── Want to avoid Heap Fetch? ➔ INCLUDE columns (Postgres) or Covering Index
🏆 Now we Know:
🧊 Partial Indexes 🧊 INCLUDE Indexes 🧊 Composite Index order tricks 🧊 BRIN Indexes 🧊 Hash Indexes 🧊 How to choose best Index
MySQL InnoDB: Directly find the row inside the PK B-tree (no extra lookup).
✅ MySQL is a little faster here because it needs only 1 step!
2. SELECT username FROM users WHERE user_id = 102; (Only 1 Column)
PostgreSQL: Might do an Index Only Scan if all needed data is in the index (very fast).
MySQL: Clustered index contains all columns already, no special optimization needed.
✅ Both can be very fast, but PostgreSQL shines if the index is “covering” (i.e., contains all needed columns). Because index table has less size than clustered index of mysql.
3. SELECT * FROM users WHERE username = 'Bob'; (Secondary Index Search)
PostgreSQL: Secondary index on username ➔ row pointer ➔ fetch table row.
MySQL: Secondary index on username ➔ get primary key ➔ clustered index lookup ➔ fetch data.
✅ Both are 2 steps, but MySQL needs 2 different B-trees: secondary ➔ primary clustered.
Consider the below situation:
SELECT username FROM users WHERE user_id = 102;
user_id is the Primary Key.
You only want username, not full row.
Now:
🔵 PostgreSQL Behavior
👉 In PostgreSQL, by default:
It uses the primary key btree to find the row pointer.
Then fetches the full row from the table (heap fetch).
👉 But PostgreSQL has an optimization called Index-Only Scan.
If all requested columns are already present in the index,
And if the table visibility map says the row is still valid (no deleted/updated row needing visibility check),
Then Postgres does not fetch the heap.
👉 So in this case:
If the primary key index also stores username internally (or if an extra index is created covering username), Postgres can satisfy the query just from the index.
✅ Result: No table lookup needed ➔ Very fast (almost as fast as InnoDB clustered lookup).
📢 Postgres primary key indexes usually don’t store extra columns, unless you specifically create an index that includes them (INCLUDE (username) syntax in modern Postgres 11+).
🟠 MySQL InnoDB Behavior
In InnoDB: Since the primary key B-tree already holds all columns (user_id, username, email), It directly finds the row from the clustered index.
So when you query by PK, even if you only need one column, it has everything inside the same page/block.
✅ One fast lookup.
🔥 Why sometimes Postgres can still be faster?
If PostgreSQL uses Index-Only Scan, and the page is already cached, and no extra visibility check is needed, Then Postgres may avoid touching the table at all and only scan the tiny index pages.
In this case, for very narrow queries (e.g., only 1 small field), Postgres can outperform even MySQL clustered fetch.
💡 Because fetching from a small index page (~8KB) is faster than reading bigger table pages.
🎯 Conclusion:
✅ MySQL clustered index is always fast for PK lookups. ✅ PostgreSQL can be even faster for small/narrow queries if Index-Only Scan is triggered.
👉 Quick Tip:
In PostgreSQL, you can force an index to include extra columns by using: CREATE INDEX idx_user_id_username ON users(user_id) INCLUDE (username); Then index-only scans become more common and predictable! 🚀
Isn’t PostgreSQL also doing 2 B-tree scans? One for secondary index and one for table (row_id)?
When you query with a secondary index, like:
SELECT * FROM users WHERE username = 'Bob';
In MySQL InnoDB, I said:
Find in secondary index (username ➔ user_id)
Then go to primary clustered index (user_id ➔ full row)
Let’s look at PostgreSQL first:
♦️ Step 1: Search Secondary Index B-tree on username.
It finds the matching TID (tuple ID) or row pointer.
TID is a pair (block_number, row_offset).
Not a B-tree! Just a physical pointer.
♦️ Step 2: Use the TID to directly jump into the heap (the table).
The heap (table) is not a B-tree — it’s just a collection of unordered pages (blocks of rows).
PostgreSQL goes directly to the block and offset — like jumping straight into a file.
🔔 Important:
Secondary index ➔ TID ➔ heap fetch.
No second B-tree traversal for the table!
🟠 Meanwhile in MySQL InnoDB:
♦️ Step 1: Search Secondary Index B-tree on username.
It finds the Primary Key value (user_id).
♦️ Step 2: Now, search the Primary Key Clustered B-tree to find the full row.
Need another B-tree traversal based on user_id.
🔔 Important:
Secondary index ➔ Primary Key B-tree ➔ data fetch.
Two full B-tree traversals!
Real-world Summary:
♦️ PostgreSQL
Secondary index gives a direct shortcut to the heap.
One B-tree scan (secondary) ➔ Direct heap fetch.
♦️ MySQL
Secondary index gives PK.
Then another B-tree scan (primary clustered) to find full row.
✅ PostgreSQL does not scan a second B-tree when fetching from the table — just a direct page lookup using TID.
✅ MySQL does scan a second B-tree (primary clustered index) when fetching full row after secondary lookup.
Is heap fetch a searching technique? Why is it faster than B-tree?
📚 Let’s start from the basics:
When PostgreSQL finds a match in a secondary index, what it gets is a TID.
♦️ A TID (Tuple ID) is a physical address made of:
Block Number (page number)
Offset Number (row slot inside the page)
Example:
TID = (block_number = 1583, offset = 7)
🔵 How PostgreSQL uses TID?
It directly calculates the location of the block (disk page) using block_number.
It reads that block (if not already in memory).
Inside that block, it finds the row at offset 7.
♦️ No search, no btree, no extra traversal — just:
Find the page (via simple number addressing)
Find the row slot
📈 Visual Example
Secondary index (username ➔ TID):
username
TID
Alice
(1583, 7)
Bob
(1592, 3)
Carol
(1601, 12)
♦️ When you search for “Bob”:
Find (1592, 3) from secondary index B-tree.
Jump directly to Block 1592, Offset 3.
Done ✅!
Answer:
Heap fetch is NOT a search.
It’s a direct address lookup (fixed number).
Heap = unordered collection of pages.
Pages = fixed-size blocks (usually 8 KB each).
TID gives an exact GPS location inside heap — no searching required.
That’s why heap fetch is faster than another B-tree search:
No binary search, no B-tree traversal needed.
Only a simple disk/memory read + row offset jump.
🌿 B-tree vs 📁 Heap Fetch
Action
B-tree
Heap Fetch
What it does
Binary search inside sorted tree nodes
Direct jump to block and slot
Steps needed
Traverse nodes (root ➔ internal ➔ leaf)
Directly read page and slot
Time complexity
O(log n)
O(1)
Speed
Slower (needs comparisons)
Very fast (direct)
🎯 Final and short answer:
♦️ In PostgreSQL, after finding the TID in the secondary index, the heap fetch is a direct, constant-time (O(1)) access — no B-tree needed! ♦️ This is faster than scanning another B-tree like in MySQL InnoDB.
🧩 Our exact question:
When we say:
Jump directly to Block 1592, Offset 3.
We are thinking:
There are thousands of blocks.
How can we directly jump to block 1592?
Shouldn’t that be O(n) (linear time)?
Shouldn’t there be some traversal?
🔵 Here’s the real truth:
No traversal needed.
No O(n) work.
Accessing Block 1592 is O(1) — constant time.
📚 Why?
Because of how files, pages, and memory work inside a database.
When PostgreSQL stores a table (the “heap”), it saves it in a file on disk. The file is just a long array of fixed-size pages.
Each page = 8KB (default in Postgres).
Each block = 1 page = fixed 8KB chunk.
Block 0 is the first 8KB.
Block 1 is next 8KB.
Block 2 is next 8KB.
…
Block 1592 = (1592 × 8 KB) offset from the beginning.
✅ So block 1592 is simply located at 1592 × 8192 bytes offset from the start of the file.
✅ Operating systems (and PostgreSQL’s Buffer Manager) know exactly how to seek to that byte position without reading everything before it.
Let’s walk through a real-world example using a schema we are already working on: a shopping app that sells clothing for women, men, kids, and infants.
We’ll look at how candidate keys apply to real tables like Users, Products, Orders, etc.
Here, a combination of order_id and product_id uniquely identifies a row — i.e., what product was ordered in which order — making it a composite candidate key, and we’ve selected it as the primary key.
👀 Summary of Candidate Keys by Table
Table
Candidate Keys
Primary Key Used
Users
user_id, email, username
user_id
Products
product_id, sku
product_id
Orders
order_id, order_number
order_id
OrderItems
(order_id, product_id)
(order_id, product_id)
Let’s explore how to implement candidate keys in both SQL and Rails (Active Record). Since we are working on a shopping app in Rails 8, I’ll show how to enforce uniqueness and data integrity in both layers:
🔹 1. Candidate Keys in SQL (PostgreSQL Example)
Let’s take the Users table with multiple candidate keys (email, username, and user_id).
CREATE TABLE users (
user_id SERIAL PRIMARY KEY,
email VARCHAR(255) NOT NULL UNIQUE,
username VARCHAR(100) NOT NULL UNIQUE,
phone_number VARCHAR(20)
);
user_id: chosen as the primary key
email and username: candidate keys, enforced via UNIQUE constraints
If you examine the above table, there will be repetitive product items if consider for a size of an item there comes so many colours. We need to create different product rows for each size different colours.
So Let’s split the table into two.
1. Product Table
class CreateProducts < ActiveRecord::Migration[8.0]
def change
def change
create_table :products do |t|
t.string :name
t.text :description
t.string :category # women, men, kids, infants
t.decimal :rating, precision: 2, scale: 1, default: 0.0
t.timestamps
end
add_index :products, :category
end
end
end
2. Product Variant Table
class CreateProductVariants < ActiveRecord::Migration[8.0]
def change
create_table :product_variants do |t|
t.references :product, null: false, foreign_key: true
t.string :sku, null: false
t.decimal :price, precision: 10, scale: 2
t.string :size
t.string :color
t.integer :stock_quantity, default: 0
t.jsonb :specs, default: {}, null: false
t.timestamps
end
# GIN index for fast JSONB attribute searching
add_index :product_variants, :specs, using: :gin
add_index :product_variants, [ :product_id, :size, :color ], unique: true
add_index :product_variants, :sku, unique: true
end
end
Data normalization is a core concept in database design that helps organize data efficiently, eliminate redundancy, and ensure data integrity.
🔍 What Is Data Normalization?
Normalization is the process of structuring a relational database in a way that:
Reduces data redundancy (no repeated data)
Prevents anomalies in insert, update, or delete operations
Improves data integrity
It breaks down large, complex tables into smaller, related tables and defines relationships using foreign keys.
🧐 Why Normalize?
Problem Without Normalization
How Normalization Helps
Duplicate data everywhere
Moves repeated data into separate tables
Inconsistent values
Enforces rules and relationships
Hard to update data
Isolates each concept so it’s updated once
Wasted storage
Reduces data repetition
📚 Normal Forms (NF)
Each Normal Form (NF) represents a level of database normalization. The most common are:
ite key (student_id, course_id), and student_name depends only on student_id, it should go into a separate table.
Think of normalization as organizing SQL tables to reduce duplication, inconsistency, and update problems.
1NF – First Normal Form
Rule: Each column should contain atomic/single values. No lists or repeating groups.
❌ Not 1NF
id
name
phone_numbers
1
John
9876, 8765
phone_numbers contains multiple values.
✅ 1NF
customers
id
name
1
John
customer_phones
id
customer_id
phone
1
1
9876
2
1
8765
Easy way to remember:
One cell = one value.
2NF – Second Normal Form
Rule: Must already be in 1NF, and every non-key column must depend on the whole primary key, not just part of it.
This matters mainly when you have a composite primary key.
❌ Not 2NF
Suppose:
order_items
order_id
product_id
order_date
product_name
qty
101
10
2026-09-12
Laptop
2
101
20
2026-09-12
Mouse
1
Primary key:
(order_id, product_id)
But:
order_date -> depends only on order_id
product_name -> depends only on product_id
qty -> depends on both
So order_date and product_name don’t depend on the whole key.
✅ 2NF
orders
order_id
order_date
101
2026-09-12
products
product_id
product_name
10
Laptop
20
Mouse
order_items
order_id
product_id
qty
101
10
2
101
20
1
Easy way to remember:
No column should depend on only part of a composite key.
3NF – Third Normal Form
Rule: Must be in 2NF, and non-key columns should not depend on other non-key columns.
❌ Not 3NF
employees
employee_id
employee_name
dept_id
dept_name
1
John
10
Engineering
2
Mary
10
Engineering
Here:
employee_id -> dept_id
dept_id -> dept_name
So:
employee_id -> dept_name
indirectly.
dept_name depends on another non-key column (dept_id).
✅ 3NF
employees
employee_id
employee_name
dept_id
1
John
10
2
Mary
10
departments
dept_id
dept_name
10
Engineering
Easy way to remember:
Non-key columns should depend on the key, the whole key, and nothing but the key.
The simplest mental model
1NF
↓
No multiple values in one cell
2NF
↓
No dependency on part of a composite key
3NF
↓
No dependency between non-key columns
Or the classic int. phrase:
3NF: Every non-key attribute depends on the key, the whole key, and nothing but the key.
Int.-friendly example
1NF → "phone = 9876,8765" ❌
split into rows ✅
2NF → (order_id, product_id) is the key
order_date depends only on order_id ❌
move it to orders ✅
3NF → dept_id -> dept_name
dept_name shouldn't live in employees ✅
move it to departments
⚖️ Normalization vs. Denormalization
✅ Normalization = Good for consistency, long-term maintenance
⚠️ Denormalization = Good for performance in read-heavy systems (like reporting dashboards)
Use normalization as a default practice, then selectively denormalize if performance requires it.
Delete button example (Rails 7+)
<%= link_to "Delete Product",
@product,
data: { turbo_method: :delete, turbo_confirm: "Are you sure you want to delete this product?" },
class: "inline-block px-4 py-2 bg-red-100 text-red-600 border border-red-300 rounded-md hover:bg-red-600 hover:text-white font-semibold transition duration-300 transform hover:scale-105" %>
Hover effects and transitions for a smooth UI experience.
Add Brand to products table
Let’s add brand column to the product table:
✗ rails g migration add_brand_to_products brand:string:
index
class AddBrandToProducts < ActiveRecord::Migration[8.0]
def change
# Add 'brand' column
add_column :products, :brand, :string
# Add index for brand
add_index :products, :brand
end
end
❗️Important Note:
❌ PostgreSQL does not support BEFORE or AFTER when adding a column.
Caused by:
PG::SyntaxError: ERROR: syntax error at or near "BEFORE" (PG::SyntaxError)
LINE 1: ...LTER TABLE products ADD COLUMN brand VARCHAR(255) BEFORE des...
PostgreSQL (default in Rails) does not support column order (they’re always returned in the order they were created).
If you’re using MySQL, you could use raw SQL for positioning as shown below.
If I USEMySQL, I would like to see the brand name as first column of the table products. You can do that by changing the migration to:
class AddBrandToProducts < ActiveRecord::Migration[8.0]
def up
execute "ALTER TABLE products ADD COLUMN brand VARCHAR(255) BEFORE description;"
add_index :products, :brand
end
def down
remove_index :products, :brand
remove_column :products, :brand
end
end
Reverting Previous Migrations
You can use Active Record’s ability to rollback migrations using the revert method:
require_relative "20121212123456_example_migration"
class FixupExampleMigration < ActiveRecord::Migration[8.0]
def change
revert ExampleMigration
create_table(:apples) do |t|
t.string :variety
end
end
end
The revert method also accepts a block of instructions to reverse. This could be useful to revert selected parts of previous migrations.
If you’ve already built a Rails 8 app using the default SQLite setup and now want to switch to PostgreSQL, here’s a clean step-by-step guide to make the transition smooth:
1.🔧 Setup PostgreSQL in macOS
🔷 Step 1: Install PostgreSQL via Homebrew
Run the following:
brew install postgresql
This created a default database cluster for me, check the output. So you can skip the Step 3.
==> Summary
🍺 /opt/homebrew/Cellar/postgresql@14/14.17_1: 3,330 files, 45.9MB
==> Running `brew cleanup postgresql@14`...
==> postgresql@14
This formula has created a default database cluster with:
initdb --locale=C -E UTF-8 /opt/homebrew/var/postgresql@14
To start postgresql@14 now and restart at login:
brew services start postgresql@14
Or, if you don't want/need a background service you can just run:
/opt/homebrew/opt/postgresql@14/bin/postgres -D /opt/homebrew/var/postgresql@14
Sometimes Homebrew does this automatically. If not:
initdb /opt/homebrew/var/postgresql@<version>
Or a more general version:
initdb /usr/local/var/postgres
Key functions of initdb: Creates a new database cluster, Initializes the database cluster’s default locale and character set encoding, Runs a vacuum command.
In essence, initdb prepares the environment for a PostgreSQL database to be used and provides a foundation for creating and managing databases within that cluster
🔷 Step 4: Create a User and Database
PostgreSQL uses a role-based access control. Create a user with superuser privileges:
# createuser creates a new Postgres user
createuser -s postgres
createuser is a shell script wrapper around the SQL command CREATE USER via the Postgres interactive terminal psql. Thus, there is nothing special about creating users via this or other methods
Then switch to psql:
psql postgres
You can also create a database:
createdb <db_name>
🔷 Step 5: Connect and Use psql
psql -d <db_name>
Inside the psql shell, try:
\l -- list databases
\dt -- list tables
\q -- quit
Then go to http://localhost:3000 and confirm everything works.
7. Check psql manually (Optional)
psql -d your_app_name_development
Then run:
\dt -- view tables
\q -- quit
8. Update .gitignore
Note: If not already added /storage/*
Make sure SQLite DBs are not accidentally committed:
/storage/*.sqlite3
/storage/*.sqlite3-journal
After moving into PostgreSQL
I was getting an issue with postgres column, where I have the following data in the migration:
# migration
t.decimal :rating, precision: 1, scale: 1
# log
ActiveRecord::RangeError (PG::NumericValueOutOfRange: ERROR: numeric field overflow
12:44:36 web.1 | DETAIL: A field with precision 1, scale 1 must round to an absolute value less than 1.
12:44:36 web.1 | )
Value passed is: 4.3. I was not getting this issue in SqLite DB.
What does precision: 1, scale: 1 mean?
precision: Total number of digits (both left and right of the decimal).
scale: Number of digits after the decimal point
If you want to store ratings like 4.3, 4.5, etc., a good setup is:
t.decimal :rating, precision: 2, scale: 1
# revert and migrate for products table
✗ rails db:migrate:down VERSION=2025031XXXXX -t
✗ rails db:migrate:up VERSION=2025031XXXXXX -t
Then go to http://localhost:3000 and confirm everything works.
For a Ruby on Rails 8 application, the choice of database depends on your specific needs, but here’s a breakdown of the best options and when to use each:
PostgreSQL (Highly Recommended)
Best overall choice for most Rails apps.
Why:
First-class support in Rails.
Advanced features like full-text search, JSONB support, CTEs, window functions.
Strong consistency and reliability.
Scales well vertically and horizontally (with tools like Citus).
Used by: GitHub, Discourse, Basecamp, Shopify.
Use if:
You’re building a standard Rails web app or API.
You need advanced query features or are handling complex data types (e.g., JSON).
SQLite (For development/testing only)
Lightweight, file-based.
Fast and easy to set up.
But not recommended for production.
Use if:
You’re building a quick prototype or local dev/testing app.
NOT for multi-user production environments.
MySQL / MariaDB
Also supported by Rails.
Can work fine for simpler applications.
Lacks some advanced features (like robust JSON support or full Postgres-style indexing).
Not the default in many modern Rails setups.
Use if:
Your team already has MySQL infrastructure or legacy systems.
You need horizontal scaling with Galera Cluster or similar setups.
Others (NoSQL like MongoDB, Redis, etc.)
Use Redis for caching and background job data (not as primary DB).
Use MongoDB or other NoSQL only if your data model really demands it (e.g., unstructured documents, event sourcing).
Recommendation Summary:
Use Case
Recommended DB
Production web/API app
PostgreSQL
Dev/prototyping/local testing
SQLite
Legacy systems/MySQL infrastructure
MySQL/MariaDB
Background jobs/caching
Redis
Special needs (e.g., documents)
MongoDB (with caution)
If you’re starting fresh or building something scalable and modern with Rails 8, go with PostgreSQL.
Let’s break that down:
💬 What does “robust JSON support” mean?
PostgreSQL supports a special column type: json and jsonb, which lets you store structured JSON data directly in your database — like hashes or objects.
Why it matters:
You can store dynamic data without needing to change your schema.
You can query inside the JSON using SQL (->, ->>, @>, etc.).
You can index parts of the JSON — for speed.
🔧 Example:
You have a products table with a specs column that holds tech specs in JSON:
SELECT * FROM products WHERE specs->>'color' = 'black';
Or check if the JSON contains a value:
SELECT * FROM products WHERE specs @> '{"brand": "Libas"}';
You can even indexspecs->>'color' to make these queries fast.
💬 What does “full Postgres-style indexing” mean?
PostgreSQL supports a wide variety of powerful indexing options, which improve query performance and flexibility.
⚙️ Types of Indexes PostgreSQL supports:
Index Type
Use Case
B-Tree
Default; used for most equality and range searches
GIN (Generalized Inverted Index)
Fast indexing for JSON, arrays, full-text search
Partial Indexes
Index only part of the data (e.g., WHERE active = true)
Expression Indexes
Index a function or expression (e.g., LOWER(email))
Covering Indexes (INCLUDE)
Fetch data directly from the index, avoiding table reads
B-Tree Indexes: B-tree indexes are more suitable for single-value columns.
When to Use GIN Indexes: When you frequently search for specific elements within arrays, JSON documents, or other composite data types.
Example for GIN Indexes: Imagine you have a table with a JSONB column containing document metadata. A GIN index on this column would allow you to quickly find all documents that have a specific author or belong to a particular category.
Why does this matter for our shopping app?
We can store and filter products with dynamic specs (e.g., kurtas, shorts, pants) without new columns.
Full-text search on product names/descriptions.
Fast filters: color = 'red' AND brand = 'Libas' even if those are stored in JSON.
Index custom expressions like LOWER(email) for case-insensitive login.
💬 What are Common Table Expressions (CTEs)?
CTEs are temporary result sets you can reference within a SQL query — like defining a mini subquery that makes complex SQL easier to read and write.
WITH recent_orders AS (
SELECT * FROM orders WHERE created_at > NOW() - INTERVAL '7 days'
)
SELECT * FROM recent_orders WHERE total > 100;
Breaking complex queries into readable parts.
Re-using result sets without repeating subqueries.
In Rails (via with from gems like scenic or with_cte):
Window functions perform calculations across rows related to the current row — unlike aggregate functions, they don’t group results into one row.
🔧 Example: Rank users by their score within each team:
SELECT
user_id,
team_id,
score,
RANK() OVER (PARTITION BY team_id ORDER BY score DESC) AS rank
FROM users;
Use cases:
Ranking rows (like leaderboards).
Running totals or moving averages.
Calculating differences between rows (e.g. “How much did this order increase from the last?”).
🛤 In Rails:
Window functions are available through raw SQL or Arel. Here’s a basic example:
User
.select("user_id, team_id, score, RANK() OVER (PARTITION BY team_id ORDER BY score DESC) AS rank")
CTEs and Window functions are fully supported in PostgreSQL, making it the go-to DB for any Rails 8 app that needs advanced querying.
JSONB Support
JSONB stands for “JSON Binary” and is a binary representation of JSON datathat allows for efficient storage and retrieval of complex data structures.
This can be useful when you have data that doesn’t fit neatly into traditional relational database tables, such as nested or variable-length data structures.
Absolutely — storing JSON in a relational database (like PostgreSQL) can be super powerful when used wisely. It gives you schema flexibility without abandoning the structure and power of SQL. Here are real-world use cases for using JSON columns in relational databases:
Here are real-world use cases for using JSON columns in relational databases:
🔧 1. Flexible Metadata / Extra Attributes
Let users store arbitrary attributes that don’t require schema changes every time.
A lightweight version that only declares attribute accessors for keys inside a JSON column. Doesn’t include serialization logic — so you usually use it with a json/jsonb/text column that already works as a Hash.
👉 Example:
class User < ApplicationRecord
store_accessor :settings, :theme, :notifications
end
This gives you:
user.theme, user.theme=
user.notifications, user.notifications=
🤔 When to Use Each?
Feature
When to Use
store
When you need both serialization and accessors
store_accessor
When your column is already serialized (jsonb, etc.)
If you’re using PostgreSQL with jsonb columns — it’s more common to just use store_accessor.
Querying JSON Fields
User.where("settings ->> 'theme' = ?", "dark")
Or if you’re using store_accessor:
User.where(theme: "dark")
💡 But remember: you’ll only be able to query these fields efficiently if you’re using jsonb + proper indexes.
🔥 Conclusion:
PostgreSQL can store, search, and index inside JSON fields natively.
This lets you keep your schema flexible and your queries fast.
Combined with its advanced indexing, it’s ideal for a modern e-commerce app with dynamic product attributes, filtering, and searching.
To install and set up PostgreSQL on macOS, you have a few options. The most common and cleanest method is using Homebrew. Here’s a step-by-step guide:
Active Record (AR) is the heart of Ruby on Rails when it comes to database interactions. Writing efficient and readable queries is crucial for application performance and maintainability. This guide will help you master Active Record queries with real-world examples and best practices.
Setting Up a Sample Database
To demonstrate complex Active Record queries, let’s create a Rails app with a sample database structure containing multiple tables.
Generate Models & Migrations
rails new MyApp --database=postgresql
cd MyApp
rails g model User name:string email:string
rails g model Post title:string body:text user:references
rails g model Comment body:text user:references post:references
rails g model Category name:string
rails g model PostCategory post:references category:references
rails g model Like user:references comment:references
rails db:migrate
Database Schema Overview
users: Stores user information.
posts: Stores blog posts written by users.
comments: Stores comments on posts, linked to users and posts.
categories: Stores post categories.
post_categories: Join table for posts and categories.
likes: Stores likes on comments by users.
Basic Active Record Queries
1. Fetching All Records
User.all # Returns all users (Avoid using it directly on large datasets as it loads everything into memory)
⚠️ User.all can lead to performance issues if the table contains a large number of records. Instead, prefer pagination (User.limit(100).offset(0)) or batch processing (User.find_each).
2. Finding a Specific Record
User.find(1) # Finds a user by ID
User.find_by(email: 'john@example.com') # Finds by attribute
3. Filtering with where vs having
Post.where(user_id: 2) # Fetch all posts by user with ID 2
Difference between where and having:
where is used for filtering records before grouping.
having is used for filtering after group operations.
Example:
Post.group(:user_id).having('COUNT(id) > ?', 5) # Users with more than 5 posts
4. Ordering Results
User.order(:name) # Order users alphabetically
Post.order(created_at: :desc) # Order posts by newest first
5. Limiting Results
Post.limit(5) # Get the first 5 posts
6. Selecting Specific Columns
User.select(:id, :name) # Only fetch ID and name
7. Fetching Users with a Specific Email Domain
User.where("email LIKE ?", "%@gmail.com")
8. Fetching the Most Recent Posts
Post.order(created_at: :desc).limit(5)
9. Using pluck for Efficient Data Retrieval
User.pluck(:email) # Fetch only emails as an array
10. Checking if a Record Exists Efficiently
User.exists?(email: 'john@example.com')
11. Including Associations (eager loading to avoid N+1 queries)
13. Fetching Users, Their Posts, and the Count of Comments on Each Post
User.joins(posts: :comments)
.group('users.id', 'posts.id')
.select('users.id, users.name, posts.id AS post_id, COUNT(comments.id) AS comment_count')
.order('comment_count DESC')
Importance of inverse_of in Model Associations
What is inverse_of?
The inverse_of option in Active Record associations helps Rails correctly link objects in memory, avoiding unnecessary database queries and ensuring bidirectional association consistency.
Example Usage
class User < ApplicationRecord
has_many :posts, inverse_of: :user
end
class Post < ApplicationRecord
belongs_to :user, inverse_of: :posts
end
Why Use inverse_of?
Performance Optimization: Prevents extra queries by using already loaded objects.
Ensures Data Consistency: Updates associations without additional database fetches.
Enables Nested Attributes: Helps when using accepts_nested_attributes_for.
Example:
user = User.new(name: 'Alice')
post = user.posts.build(title: 'First Post')
post.user == user # True without needing an additional query
Best Practices to use in Rails Projects
1. Using Scopes for Readability
class Post < ApplicationRecord
scope :recent, -> { order(created_at: :desc) }
end
Post.recent.limit(10) # Fetch recent posts
2. Using find_each for Large Datasets
User.find_each(batch_size: 100) do |user|
puts user.email
end
3. Avoiding SELECT * for Performance
User.select(:id, :name).load
4. Avoiding N+1 Queries with includes
Post.includes(:comments).each do |post|
puts post.comments.count
end
Conclusion
Mastering Active Record queries is essential for writing performant and maintainable Rails applications. By using joins, scopes, batch processing, and eager loading, you can write clean and efficient queries that scale well.
Do you have any favorite Active Record query tricks? Share them in the comments!
Order
.where("created_at < ?", last_created_at)
.order(created_at: :desc)
.limit(20)
This is called keyset pagination or cursor pagination.
Scenario 6 – SELECT *
Query:
SELECT*
FROM users;
Returns:
id
name
email
city
address
bio
avatar
...
Suppose the page only displays:
name
city
Why fetch everything?
Better:
SELECT
name,
city
FROM users;
Rails:
User.select(:name, :city)
Scenario 7 – COUNT(*)
Suppose:
SELECTCOUNT(*)
FROM orders;
On:
300 million rows
Question:
Can this be slow?
Yes.
Because PostgreSQL must count visible rows.
Unlike some databases, PostgreSQL generally doesn’t maintain an exact row count that’s instantly available for arbitrary COUNT(*).
Scenario 8 – DISTINCT
Query:
SELECTDISTINCT users.*
FROM users
JOIN orders
ON users.id=orders.user_id;
Question:
Why is DISTINCT needed?
Because JOIN duplicates users.
Could EXISTS express the requirement more directly?
SELECT*
FROM users u
WHEREEXISTS(
SELECT1
FROM orders o
WHERE o.user_id=u.id
);
Sometimes that’s clearer.
Scenario 9 – Functions in WHERE
Query:
SELECT*
FROM users
WHERE LOWER(email)='john@example.com';
Normal email index?
Not useful.
Solution:
CREATE INDEX idx_lower_email
ON users(LOWER(email));
Expression index.
Scenario 10 – Too Many Indexes
Suppose:
users
↓
12 indexes
Problem?
Every INSERT must update:
Table
+
12 indexes
Indexes speed reads but slow writes.
Always consider the workload.
Optimization Decision Tree
When a query is slow, ask:
Is PostgreSQL scanning too many rows?
│
▼
Yes
│
▼
Would an index help?
│
▼
Yes
│
▼
Do I already have one?
│
▼
No
│
▼
Create the correct index.
But also ask:
Am I returning unnecessary data?
Am I sorting unnecessarily?
Am I joining unnecessarily?
Am I executing the query too many times?
Real Rails Optimization Example
Suppose this page loads slowly:
@orders = Order
.where(status: "completed")
.order(created_at: :desc)
.limit(20)
Questions:
Is there an index on status?
Is there an index on created_at?
Would a composite index help?
Potential solution:
CREATE INDEX idx_orders_status_created_at
ON orders(status, created_at DESC);
Why?
Because the query filters by status and orders by created_at.
Senior Interview Exercise
Suppose you see:
SELECT*
FROM orders
WHERE user_id =100
ORDERBY created_at DESC
LIMIT20;
Which index would you create?
Many people answer:
(user_id)
(created_at)
A stronger answer is:
CREATE INDEX idx_orders_user_created
ON orders(user_id, created_at DESC);
Because it supports both the filter and the ordering in one index.
Common Performance Mistakes
Mistake 1
Adding indexes without measuring.
Mistake 2
Using SELECT * everywhere.
Mistake 3
Ignoring N+1 queries.
Mistake 4
Using huge OFFSET values.
Mistake 5
Creating duplicate indexes.
Mistake 6
Ignoring EXPLAIN ANALYZE.
Senior-Level Mental Model
Every query has a “cost.”
The cost comes from:
Rows read
+
Rows sorted
+
Rows joined
+
Rows transferred
+
Application round trips
The goal of optimisation is to reduce one or more of these.
Practical Exercises
Exercise 1
Create:
CREATE INDEX idx_orders_user_created
ON orders(user_id, created_at DESC);
Run:
EXPLAIN ANALYZE
SELECT*
FROM orders
WHERE user_id=1
ORDERBY created_at DESC
LIMIT20;
Observe whether PostgreSQL can avoid an explicit Sort.
Exercise 2
Compare:
SELECT*
FROM users;
with:
SELECT name, city
FROM users;
Think about the amount of data returned.
Exercise 3
Write three versions of:
“Find users with completed orders.”
Using:
JOIN
EXISTS
IN
Then compare their execution plans.
Exercise 4
Find a query that performs a Seq Scan.
Add an appropriate index.
Run EXPLAIN ANALYZE again.
What changed?
Interview Case Study
Imagine you’re in a senior Rails interview.
The interviewer says:
“A customer reports that the Orders page takes 6 seconds to load.”
A strong answer isn’t:
“I’ll add an index.”
A stronger answer is:
Reproduce the issue.
Identify the SQL generated by ActiveRecord.
Run EXPLAIN ANALYZE.
Inspect scan types, joins, and sort operations.
Check existing indexes.
Decide whether the fix belongs in the SQL, indexes, ActiveRecord code, or schema.
Measure again after the change.
That systematic approach demonstrates senior-level thinking.
Homework
Build a small benchmark using your practice schema.
Populate:
100,000 users
500,000 orders
Measure these queries before and after adding indexes:
Find a user by email.
Find recent orders for a user.
Find completed orders.
Find users with no orders.
For each query, record:
Execution plan
Execution time
Scan type
Rows estimated
Rows returned
Explain why PostgreSQL chose each plan.
What’s Next?
At this point, you’re already covering topics that many experienced Rails developers never study in depth.
For Day 8, I recommend Window Functions:
ROW_NUMBER()
RANK()
DENSE_RANK()
LAG()
LEAD()
Running totals
Moving averages
Top N per group
Window functions are common in reporting, analytics, and senior backend interviews because they solve problems that are difficult or inefficient with plain GROUP BY. Understanding them will significantly broaden your SQL toolkit.
Today we’ll go beyond “create an index” and learn how senior backend engineers decide which index to create.
This is one of the most valuable topics for PostgreSQL and Rails interviews.
By the end of this lesson, you should be able to answer questions like:
Why did you create that index?
instead of just saying
Because the query was slow.
Today’s Goals
We’ll learn:
Composite Indexes
Leftmost Prefix Rule
Covering Indexes (Index Only Scan)
Included Columns (INCLUDE)
Partial Indexes
Expression Indexes
Unique Indexes
Choosing the right index
B-tree vs Hash vs GIN vs GiST vs BRIN
Rails migration examples
Real production examples
Part 1 – Let’s Create a Realistic Table
We’ll use a slightly more realistic table.
DROP TABLE IF EXISTS users;
CREATE TABLE users (
id BIGSERIAL PRIMARY KEY,
email TEXT,
city TEXT,
age INTEGER,
active BOOLEAN,
created_at TIMESTAMP
);
Populate it:
INSERT INTO users(email, city, age, active, created_at)
SELECT
'user' || i || '@example.com',
CASE
WHEN i % 4 = 0 THEN 'Boston'
WHEN i % 4 = 1 THEN 'Chicago'
WHEN i % 4 = 2 THEN 'New York'
ELSE 'Dallas'
END,
20 + (i % 40),
(i % 10 <> 0),
NOW() - (i || ' days')::interval
FROM generate_series(1,100000) i;
Part 2 – Composite Indexes
Suppose your application frequently runs:
SELECT *
FROM users
WHERE city = 'Chicago'
AND age = 30;
Many developers think:
I’ll create two indexes.
CREATE INDEX idx_users_city
ON users(city);
CREATE INDEX idx_users_age
ON users(age);
Sometimes PostgreSQL can combine them using a Bitmap Index Scan.
But often, a composite index is even better.
CREATE INDEX idx_users_city_age
ON users(city, age);
How PostgreSQL Stores It
Think of it as sorting by the first column, then by the second.
Conceptually:
Boston
20
21
22
23
Chicago
20
21
22
23
24
25
Dallas
...
Notice:
Everything is ordered by:
city
↓
age
The Leftmost Prefix Rule
This is probably the most important composite-index interview question.
Suppose you have:
(city, age)
Will it help?
Query 1
WHERE city='Chicago'
✅ Yes
Because the index starts with city.
Query 2
WHERE city='Chicago'
AND age=30
✅ Yes
Perfect.
Query 3
WHERE age=30
❌ Usually No
Why?
Imagine a dictionary.
Can you find:
age = 30
without first knowing the city?
No.
The index is organised by city first.
Senior Interview Question
Which is better?
(city, age)
or
(age, city)
Answer:
It depends on your query patterns.
Suppose:
95% of queries are:
WHERE city='Chicago'
Choose:
(city, age)
Suppose:
95% are:
WHERE age=30
Choose:
(age, city)
There is no universally “better” order.
Practical Exercise
Create:
CREATE INDEX idx_users_city_age
ON users(city, age);
Now test:
EXPLAIN ANALYZE
SELECT*
FROM users
WHERE city='Chicago';
Test:
EXPLAIN ANALYZE
SELECT*
FROM users
WHERE city='Chicago'
AND age=30;
Test:
EXPLAIN ANALYZE
SELECT*
FROM users
WHERE age=30;
Observe which queries use the composite index.
Part 3 – Covering Indexes
Suppose you run:
SELECT email
FROM users
WHERE email='user500@example.com';
Question:
Why read the whole table?
The index already contains:
email
↓
pointer
If PostgreSQL can answer the query using only the index, it may perform an:
Index Only Scan
instead of:
Index Scan
Why Is It Faster?
Index Scan:
Index
↓
Find pointer
↓
Read table
↓
Return email
Index Only Scan:
Index
↓
Return email
The table doesn’t need to be accessed.
Important Detail
Index Only Scans are only possible when PostgreSQL knows that the table pages are “all-visible” via the Visibility Map, which is maintained by VACUUM.
We’ll study this in PostgreSQL Internals.
Try It
Create:
CREATE INDEX idx_users_email
ON users(email);
Run:
EXPLAIN ANALYZE
SELECT email
FROM users
WHERE email='user90000@example.com';
Sometimes you’ll see:
Index Only Scan
Sometimes:
Index Scan
We’ll later learn why.
Part 4 – INCLUDE Columns
Suppose your application frequently runs:
SELECT
email,
city
FROM users
WHERE email='user100@example.com';
A normal email index stores:
email
↓
pointer
The planner still needs to visit the table to fetch city.
PostgreSQL allows:
CREATE INDEX idx_users_email_include
ON users(email)
INCLUDE(city);
Now:
email is part of the searchable index key.
city is stored in the index payload.
This can allow an Index Only Scan for:
SELECT email, city
...
without making city part of the search order.
Interview Question
Difference between:
(email, city)
and
(email)
INCLUDE(city)
Answer:
city in a composite index affects the index ordering and can be used for searching.
city in INCLUDE cannot be searched efficiently but can be returned without accessing the table.
Part 5 – Partial Indexes
One of PostgreSQL’s best features.
Suppose:
100,000 users
90,000 active
10,000 inactive
Your application only searches active users.
Instead of indexing everybody:
CREATE INDEX idx_users_active
ON users(active);
Create:
CREATE INDEX idx_active_email
ON users(email)
WHERE active=true;
Now the index contains only active users.
Advantages:
Smaller index
Faster scans
Less maintenance
Query
SELECT*
FROM users
WHERE active=true
AND email='user100@example.com';
Perfect candidate.
Rails Migration
add_index:users,
:email,
where:"active = true"
Part 6 – Expression Indexes
Suppose users log in with:
WHERE LOWER(email)=LOWER(?)
Without an expression index:
PostgreSQL can’t efficiently use a normal email index because you’re applying a function to the column.
Create:
CREATE INDEX idx_users_lower_email
ON users(LOWER(email));
Now:
SELECT*
FROM users
WHERE LOWER(email)=LOWER('JOHN@test.com');
can use the index.
Rails Example
User.where(
"LOWER(email)=?",
email.downcase
)
Expression indexes are extremely common for case-insensitive searches.
Part 7 – Unique Indexes
Earlier we learned:
email UNIQUE
Internally PostgreSQL implements this using a unique index.
You can also create one directly:
CREATEUNIQUE INDEX idx_users_email_unique
ON users(email);
Now duplicates are impossible.
Interview Question
Difference between:
UNIQUECONSTRAINT
and
CREATEUNIQUE INDEX
Practically, both enforce uniqueness.
The preferred way for business rules is usually a UNIQUE constraint, which PostgreSQL implements using a unique index under the hood.
Part 8 – Index Types
So far we’ve only used:
B-tree
PostgreSQL supports several index types.
B-tree (Default)
Good for:
=
<
BETWEEN
ORDER BY
Most common.
Hash
Good for:
=
only.
Rarely needed because B-tree also supports equality efficiently.
GIN
Great for:
JSONB
Arrays
Full-text search
Rails examples:
where("tags @> ARRAY['ruby']")
or
where("metadata @> ?", ...)
GiST
Useful for:
Geospatial data
PostGIS
Range types
BRIN
Designed for huge tables where rows are naturally ordered.
Example:
Logs
Millions of rows
Ordered by timestamp
A BRIN index is tiny compared with a B-tree.
Quick Comparison
Index
Best Use
B-tree
Default choice
Hash
Equality only
GIN
JSONB, arrays, full-text
GiST
Geometry, ranges
BRIN
Huge sequential tables
Part 9 – Real Rails Examples
Login
User.find_by(email:params[:email])
Index:
(email)
User Orders
user.orders
Index:
(user_id)
Dashboard
Order.where(status:"pending")
Maybe:
(status)
But ask:
How selective is status?
If 95% are pending, maybe not.
Recent Orders
Order
.order(created_at::desc)
.limit(20)
Good candidate:
(created_at)
Or even:
(created_at DESC)
Part 10 – Choosing the Right Index
Never ask:
Which index can I create?
Ask:
Which queries does my application actually run?
Example:
95%
WHERE email=?
Index email.
Example:
95%
WHERE city='Chicago'
Index city.
Example:
95%
WHERE city='Chicago'
AND age=30
Composite index.
Indexes should be driven by query patterns, not by table columns.
Common Mistakes
Mistake 1
Creating:
(city)
(age)
when almost every query filters on both together.
Mistake 2
Wrong column order.
(age, city)
when almost every query starts with city.
Mistake 3
Using functions without expression indexes.
LOWER(email)
Mistake 4
Indexing everything.
Indexes are not free.
Senior-Level Insights
Composite indexes should reflect how your application filters data, not simply the table schema.
Partial indexes are often a better solution than full indexes when only a subset of rows is queried frequently.
Expression indexes solve a very common performance problem when functions are applied in WHERE clauses.
Covering indexes reduce table lookups and can enable Index Only Scans.
The best index is the one that matches your most common query pattern—not necessarily the one that indexes the most columns.
Practical Exercises
Exercise 1
Create:
(city, age)
Run:
EXPLAIN ANALYZE
SELECT*
FROM users
WHERE city='Boston';
Exercise 2
Run:
SELECT*
FROM users
WHERE city='Boston'
AND age=35;
Observe the plan.
Exercise 3
Run:
SELECT*
FROM users
WHERE age=35;
Explain why the composite index is or isn’t used.
Exercise 4
Create:
CREATE INDEX idx_lower_email
ON users(LOWER(email));
Run:
EXPLAIN ANALYZE
SELECT*
FROM users
WHERE LOWER(email)=LOWER('user100@example.com');
Exercise 5
Create a partial index:
CREATE INDEX idx_active_users
ON users(email)
WHERE active=true;
Run:
SELECT*
FROM users
WHERE active=true
AND email='user100@example.com';
Compare the execution plan with and without the partial index.
Homework
Create and test:
A composite index
A partial index
An expression index
A unique index
For each index, answer:
Which query benefits?
Why?
Would a different index be better?
Use EXPLAIN ANALYZE to verify your assumptions.
Day 6C Preview
Next, we’ll become execution plan detectives.
We’ll take real EXPLAIN ANALYZE outputs like:
Gather
Hash Join
Nested Loop
Memoize
Bitmap Heap Scan
Bitmap Index Scan
Sort
Aggregate
Limit
Materialize
and decode every single line.
By the end of Day 6C, we’ll be able to sit in a senior interview, look at a PostgreSQL execution plan, and explain not just what PostgreSQL did, but why it chose that plan. That is a skill that sets experienced backend engineers apart.