Enums are one of those features developers use frequently – especially in frameworks like Rails – but many developers never fully understand why enums exist, what problem they solve, or how they are implemented internally. In Rails, enums appear deceptively simple:
enumstatus: { pending:0, paid:1, failed:2 }
But behind this tiny line lies an important software design concept used across programming languages, databases, compilers, APIs, operating systems, and application architecture.
This article explains the complete picture of enums:
Why enums exist
How they differ from other data structures
How Rails maps enums to integers internally
Whether enums are tied to SQL/databases
How ActiveRecord::Enum works under the hood
Real-world benefits and tradeoffs developers should know
What Is an Enum?
An Enum (Enumeration) is a restricted set of named values representing a finite group of states or options.
Example:
status=:pending
Possible statuses may be:
:pending
:processing
:completed
:failed
Instead of allowing any arbitrary value, enums constrain the system to a known set of valid states.
Why Do Enums Exist?
Enums solve several important problems in software systems.
1. Prevent Invalid States
Without enums:
order.status="asdfgh"
This may accidentally enter the database and corrupt business logic.
Enums restrict allowed values:
enumstatus: {
pending:0,
processing:1,
completed:2
}
Now Rails only allows known states.
2. Improve Readability
Compare:
iforder.status==2
vs
iforder.completed?
Enums convert meaningless numbers into expressive business language.
3. Save Storage Space
Integers are smaller and faster than strings.
Instead of storing:
"processing"
the DB stores:
1
This improves:
indexing
query performance
storage efficiency
4. Standardize State Management
Enums centralize valid states:
Order.statuses
returns:
{
"pending"=>0,
"processing"=>1,
"completed"=>2
}
This becomes a single source of truth.
5. Enable Better APIs & DSLs
Rails automatically generates methods:
order.pending?
order.completed!
Order.processing
Enums create expressive domain APIs.
How Enums Differ From Other Data Structures
Enums are NOT collections like arrays or hashes.
They represent a finite state system.
🔹 Enum vs Array
Array:
statuses= ["pending", "paid", "failed"]
Problem:
no constraints
no semantic meaning
no mapping behavior
no helper methods
🔹 Enum vs Hash
Hash:
STATUSES= {
pending:0,
paid:1
}
Closer, but still missing:
validations
query scopes
state predicates
DSL methods
Rails enums internally use hashes, but add behavior around them.
🔹 Enum vs Constants
Constants:
PENDING=0
PAID=1
Problem:
scattered
harder to manage
no grouped state semantics
Enums organize states cohesively.
🌍 Are Enums Related Only to SQL or Databases?
❌ Absolutely not.
Enums exist in:
C
Java
Rust
Swift
TypeScript
GraphQL
Operating systems
Compilers
APIs
State machines
Enums are a general programming concept, not a database feature.
Example: TypeScript Enum
enumStatus{
Pending,
Processing,
Completed
}
Example: Java Enum
enumStatus{
PENDING,
PROCESSING,
COMPLETED
}
Example: PostgreSQL Native Enum
CREATE TYPE status AS ENUM (
'pending',
'processing',
'completed'
);
This is database-level enum support.
🏗️ How Rails Implements Enums
Rails provides:
ActiveRecord::Enum
located in:
activerecord/lib/active_record/enum.rb
When you write:
classOrder<ApplicationRecord
enumstatus: {
pending:0,
processing:1,
completed:2
}
end
Rails dynamically generates:
1️⃣ Attribute Mapping
order.status
# => "pending"
Internally stored as:
0
in the database.
2️⃣ Predicate Methods
order.pending?
order.completed?
3️⃣ Bang Methods
order.completed!
Equivalent to:
order.update!(status::completed)
4️⃣ Query Scopes
Order.pending
Order.completed
Generated automatically.
5️⃣ Mapping Helpers
Order.statuses
Returns:
{
"pending"=>0,
"processing"=>1,
"completed"=>2
}
How Rails Maps Enum Values to Integers
Internally Rails stores:
{
pending:0,
processing:1,
completed:2
}
When assigning:
order.status=:processing
Rails converts:
:processing->1
before writing to DB.
When reading:
1->"processing"
This conversion is handled through ActiveRecord attribute type casting.
Database Example
Ruby:
order.status
# => "completed"
Actual DB value:
status =2
Why Integers Are Commonly Used
Integers:
are compact
index efficiently
compare faster
are DB-friendly
This is why Rails originally used integer-backed enums.
Important Enum Pitfall: Order Matters
This is VERY important.
Dangerous
enumstatus: [:pending, :processing, :completed]
Rails maps automatically:
pending->0
processing->1
completed->2
If you later insert:
[:pending, :draft, :processing, :completed]
Everything shifts:
processing becomes 2
completed becomes 3
💥 Existing DB data breaks.
Correct (recommended)
Always use explicit mapping:
enumstatus: {
pending:0,
processing:1,
completed:2
}
String-Based Enums in Rails
Rails also supports string-backed enums:
enumstatus: {
pending:"pending",
completed:"completed"
}
Benefits:
human-readable DB values
safer migrations
easier debugging
Tradeoff:
slightly larger storage
slightly slower indexing
🧪 Real SQL Generated by Rails Enum Queries
Order.completed
Generates:
SELECT*
FROM orders
WHERE status =2;
Even though Ruby code uses names, SQL uses integers.
🔬 Internals: How ActiveRecord::Enum Works
Internally Rails:
stores mappings in a class hash
defines methods dynamically using metaprogramming
hooks into ActiveRecord attribute casting
builds scopes automatically
Rails essentially does something conceptually like:
Rails validates at app layer, but DB still accepts:
status =999
unless constrained.
🛡️ Best Practices for Rails Enums
Use explicit mappings
enumstatus: {
pending:0,
processing:1,
completed:2
}
Add DB constraints if critical
Example PostgreSQL constraint:
CHECK(status IN(0,1,2))
Keep enums focused
Good:
status
payment_state
visibility
Bad:
everything_state
Prefer string enums when readability matters
Especially in:
analytics-heavy apps
debugging-heavy systems
APIs
Consider state machines for complex transitions
Enums represent states. State machines represent transitions.
Very different concepts.
Mental Model Every Developer Should Remember
Think of enums as:
“A controlled vocabulary for state.”
Enums are:
not collections
not just DB mappings
not Rails-specific
They are a way to model finite, meaningful states safely and expressively.
Final Takeaway
Enums exist because software systems constantly need to represent a limited set of valid states in a way that is:
efficient
readable
maintainable
safe
Rails’ ActiveRecord::Enum builds a powerful abstraction on top of simple integer (or string) mappings, generating expressive APIs, query scopes, and validations automatically through Ruby metaprogramming.
Understanding enums deeply helps developers:
design better domain models
avoid fragile state systems
write safer queries
reason about application workflows more clearly
Enums may look small, but they are one of the foundational building blocks of robust application design.
When a production database goes down – whether from a bad migration, an accidental DROP TABLE, or a rogue script – the clock starts ticking. Every minute of downtime is lost revenue, broken trust, and a very stressful Slack channel.
This post walks through how Google Cloud SQL’s backup and recovery features work, common disaster scenarios, and the recovery playbook a developer should follow for each. The examples use a typical SaaS application backed by PostgreSQL on Cloud SQL, but the principles apply broadly.
Cloud SQL Backup Fundamentals
Before anything goes wrong, you need to understand what Cloud SQL gives you out of the box and what you need to configure yourself.
Automated Backups
Cloud SQL can take daily automated backups of your instance. These are full snapshots of the entire database and are retained for a configurable window (default 7 days, max 365).
Automated backups give you daily snapshots. PITR fills the gaps by continuously archiving write-ahead logs (WAL for PostgreSQL, binary logs for MySQL). This lets you restore to any second within the retention window — not just to the time of the last backup.
# Enable PITR on an existing instance
gcloud sql instances patch my-instance \
--enable-point-in-time-recovery \
--retained-transaction-log-days=7
PITR is the single most important setting for disaster recovery. Without it, you lose every write between your last automated backup and the incident.
On-Demand Backups
You can trigger a backup manually before risky operations:
Rule of thumb: always take an on-demand backup before running migrations, bulk data operations, or any ad-hoc SQL against production.
Disaster Scenarios and Recovery Playbooks
Scenario 1: Accidental Table Drop or Data Deletion
What happened: A developer ran a DROP TABLE or DELETE FROM without a WHERE clause against production. Maybe it was a script meant for staging. Maybe an AI-generated SQL statement was executed without review.
Impact: One or more tables are gone or empty. The application is throwing 500s.
Recovery options:
Option A: PITR (best if available)
Restore to the moment just before the destructive command. You’ll need the approximate timestamp.
# Restore to a clone instance first — never restore directly over production
gcloud sql instances clone my-instance my-instance-recovery \
--point-in-time="2026-04-08T10:59:00Z"
This creates a new instance with the database state at that exact second. You can then:
Verify the data on the clone
Export the affected tables from the clone
Import them back into the production instance
# Export a specific table from the recovery clone
gcloud sql export sql my-instance-recovery gs://my-bucket/recovery/users-table.sql \
--database=myapp_production \
--table=users
# Import into production
gcloud sql import sql my-instance gs://my-bucket/recovery/users-table.sql \
--database=myapp_production
Option B: Restore from automated backup
If PITR is not enabled, restore the most recent automated backup that predates the incident.
# List available backups
gcloud sql backups list --instance=my-instance
# Restore a specific backup (this overwrites the instance)
gcloud sql backups restore BACKUP_ID --restore-instance=my-instance
Warning: Restoring a backup directly onto your production instance overwrites everything. All writes since that backup are lost. Prefer cloning to a recovery instance first.
The data gap problem:
When you restore from a backup taken at, say, 4:00 AM, but the incident happened at 11:00 AM, you lose 7 hours of data. This is the gap you’ll need to address manually. Common strategies:
Application-level event logs: If your app publishes events to a message queue (Kafka, Pub/Sub), you can replay them.
Analytics replicas: If you replicate data to BigQuery, Snowflake, or another analytics store, you can query the missing records from there and re-import them.
Audit tables: If your application logs changes to an audit table in a separate database, those records survive.
-- Example: querying BigQuery for records created during the gap window
SELECT *
FROM `project.dataset.user_actions`
WHERE created_at BETWEEN TIMESTAMP('2026-04-08 04:00:00', 'America/Vancouver')
AND TIMESTAMP('2026-04-08 11:00:00', 'America/Vancouver')
AND action_type = 'account_status_change'
You then re-ingest these records into production, typically via a script run in your application’s console or through a migration task.
Scenario 2: Interrupted Background Job
What happened: A critical scheduled job — say, one that generates weekly records for all active users — was running when the incident occurred. The database was restored from backup, but the job was killed mid-execution. Some users got their records; others didn’t.
Impact: No application errors (the data that exists is valid), but there’s a silent gap. Some users are missing records they should have.
Recovery playbook:
Step 1 — Quantify the gap
Before doing anything, measure what’s missing:
# Find users who should have a record but don't
target_date = Date.parse('2026-05-30')
users_missing = User.where(status: ['active', 'subscribed'])
.where.not(id: WeeklyRecord.where(week_date: target_date).select(:user_id))
users_missing.count
Record the count. You’ll need it for verification later.
Step 2 – Understand the generation logic
Before re-running anything, understand what the job does:
Does it check for existing records before creating? (idempotent?)
Does it behave differently based on user status? (e.g., suspended users get a different treatment)
Does it trigger side effects? (emails, webhooks, billing)
If the job is idempotent — meaning running it twice for the same user produces the same result without duplicates — you can safely re-run it for all users, not just the ones missing records. This is simpler and safer than trying to target only the gap.
Step 3 – Re-run with guardrails
Write a targeted script rather than re-triggering the entire job:
Any status-dependent logic was applied correctly (e.g., suspended users got the right treatment)
Scenario 3: Corrupted Data from a Bad Migration
What happened: A migration altered a column type, dropped a constraint, or backfilled data incorrectly. The application is running but producing wrong results.
Impact: Data is present but incorrect. This is often harder to detect than missing data.
Recovery playbook:
Don’t panic-restore. If the app is functional (just producing wrong data), you have time to assess.
Clone to a recovery instance from a backup predating the migration: gcloud sql instances clone my-instance pre-migration-clone \ --point-in-time="2026-04-07T23:00:00Z"
Diff the data between production and the clone to understand exactly what changed: -- Compare row counts SELECT 'production' as source, count(*) FROM production.orders UNION ALL SELECT 'backup' as source, count(*) FROM backup_clone.orders; -- Find rows that differ SELECT p.id, p.amount as prod_amount, b.amount as backup_amount FROM production.orders p JOIN backup_clone.orders b ON p.id = b.id WHERE p.amount != b.amount;
Write a targeted fix rather than a full restore (which would lose post-migration legitimate writes).
Write a rollback migration if the schema change itself was the problem.
Scenario 4: Full Instance Failure
What happened: The Cloud SQL instance is unreachable – maybe a zone outage, maybe accidental instance deletion.
Recovery options:
If the instance still exists (zone outage):
Cloud SQL instances configured for high availability will automatically failover to a standby in another zone. If you don’t have HA enabled:
Then update your application’s database connection string to point to the new instance.
Prevention Checklist
The best disaster recovery is the one you never need. Here’s what to set up before things go wrong:
Cloud SQL Configuration
# The production-ready configuration checklist
gcloud sql instances patch my-instance \
--backup-start-time=04:00 \
--enable-point-in-time-recovery \
--retained-transaction-log-days=7 \
--retained-backups-count=30 \
--deletion-protection \
--availability-type=REGIONAL
Operational Practices
1. Never run ad-hoc SQL directly against production
Use a read replica for investigative queries. If you must write, use a transaction with a manual ROLLBACK checkpoint:
BEGIN;
-- Your change here
UPDATE users SET status = 'inactive' WHERE last_login < '2025-01-01';
-- Verify before committing
SELECT count(*) FROM users WHERE status = 'inactive';
-- Only if the count looks right:
COMMIT;
-- Otherwise:
ROLLBACK;
AI tools are excellent at generating SQL, but they don’t understand your data invariants. A syntactically correct DROP TABLE or DELETE without a WHERE clause is still catastrophic. Always:
Read the generated SQL line by line
Run it on staging first
Wrap destructive operations in a transaction
Have a second pair of eyes for DDL changes
4. Maintain an analytics replica
Replicate critical tables to BigQuery or another analytics store. This serves as both an analytics platform and a recovery source. If your primary database loses data, you can query the replica for the gap window and re-ingest.
# Set up a BigQuery data transfer from Cloud SQL
bq mk --transfer_config \
--target_dataset=sql_replica \
--display_name="Production SQL Replica" \
--data_source=scheduled_query \
--schedule="every 1 hours"
5. Use IAM to restrict destructive operations
Not every developer needs cloudsql.instances.delete or direct SQL access to production:
T+5min Confirm the issue — is it a code bug or data loss?
T+10min Identify the last good backup / PITR target
T+15min Clone instance from backup (takes 5-30 min depending on size)
T+45min Verify restored data on the clone
T+60min Restore production from clone or selectively import tables
T+90min Identify the data gap (writes between backup and incident)
T+120min Query analytics replica / event logs for gap data
T+150min Re-ingest gap data, verify counts
T+180min Re-run interrupted jobs with verification
T+210min Final validation — all counts match, no duplicates, app healthy
T+240min Post-incident review
The total time depends on database size, gap complexity, and whether you had PITR enabled. With PITR, the gap is seconds. Without it, you could be looking at hours of manual data reconciliation.
Key Takeaways
Enable PITR. It’s the difference between losing seconds of data and losing hours.
Always clone to a recovery instance first. Never restore directly over production unless you have no other option.
Maintain an analytics replica. It’s your insurance policy for the data gap.
Quantify before you fix. Record counts before and after every recovery step. You can’t verify what you didn’t measure.
Understand your jobs’ idempotency. If a background job was interrupted, knowing whether it’s safe to re-run is the difference between a smooth recovery and creating a bigger mess.
Take on-demand backups before risky operations. The 30 seconds it takes could save you 4 hours of recovery.
Review all SQL before execution. Especially AI-generated SQL. Trust, but verify.
Production incidents are stressful, but with the right configuration and a clear playbook, they don’t have to be catastrophic. Set up your backups today — future you will be grateful.
📈 Optimized indexes: No need for user_id index since UNIQUE creates one automatically
Business Logic Validation:
user_id: One student per user ✅
course_id: Multiple students per course ✅
school_id: Multiple students per school ✅
This ensures referential integrity and business rules are enforced at the database level!
📁 Schema Storage Options:
Rails allows you to store the schema in SQL format instead of the default Ruby format. Let me explain the options and why you’d choose each:
1. Ruby Format (Default)
# db/schema.rb
ActiveRecord::Schema[8.0].define(version: 2025_07_09_074552) do
enable_extension "pg_catalog.plpgsql"
create_table "users", force: :cascade do |t|
t.string "first_name", limit: 100, null: false
t.string "email", limit: 150, null: false
t.datetime "created_at", null: false
t.index ["email"], name: "idx_users_email"
end
end
2. SQL Format
-- db/structure.sql
CREATE EXTENSION IF NOT EXISTS pg_catalog.plpgsql;
CREATE TYPE gender_enum AS ENUM ('male', 'female', 'not-specified');
CREATE TABLE users (
id bigserial PRIMARY KEY,
first_name varchar(100) NOT NULL,
email varchar(150) NOT NULL,
created_at timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE UNIQUE INDEX idx_users_email ON users(email);
module AcademicSqlSoftware
class Application < Rails::Application
# ... existing config ...
# Use SQL format for schema
config.active_record.schema_format = :sql
end
end
Then regenerate the schema:
# Generate SQL schema file
rails db:schema:dump
This will create db/structure.sql instead of db/schema.rb.
Comparison Table:
Feature
Ruby Format (schema.rb)
SQL Format (structure.sql)
Default
✅ Yes
❌ No
Database Agnostic
✅ Yes
❌ No (DB-specific)
Custom SQL Features
❌ Limited
✅ Full support
Views
❌ No
✅ Yes
Stored Procedures
❌ No
✅ Yes
Triggers
❌ No
✅ Yes
Custom Types/ENUMs
❌ Limited
✅ Full support
Partial Indexes
❌ Limited
✅ Yes
Check Constraints
❌ Limited
✅ Yes
Version Control
✅ Easy to diff
⚠️ Harder to diff
Readability
✅ Ruby-like
⚠️ Raw SQL
When to Use Each:
Choose SQL Format When:
✅ Using PostgreSQL-specific features (ENUMs, arrays, JSONB operators)
✅ Need database views, triggers, or stored procedures
✅ Using complex constraints (CHECK, partial indexes)
✅ Working with custom data types
✅ Team is SQL-proficient
Choose Ruby Format When:
✅ Want database portability (switching between PostgreSQL/MySQL/SQLite)
✅ Team prefers Ruby syntax
✅ Using standard Rails features only
✅ Need easier version control diffs
For Our Project:
I recommend SQL format because you’re using:
✅ PostgreSQL ENUMs (gender_enum, category_enum) – Rails 8 supports this
This will give us full control over PostgreSQL features while maintaining schema consistency! 🎯
🌱 Seed Data
1. Let’s refactor the seed file into modular components. I’ll create separate seeder classes for each model type and organize them properly.
2. Let’s me create individual seeder classes for each model, the seeders for the dependent models (orders and students), refactor the main seeds.rb file to use the new modular structure, create a documentation file for the new modular structure.
3. I’ve successfully refactored our seed file into a modular, maintainable architecture. Here’s what we’ve created:
Inheritance from BaseSeeder for shared functionality
Comprehensive documentation
✅ Flexibility:
Easy to add new models
Environment-specific configuration
Configurable record counts
✅ Robustness:
Proper dependency handling
Constraint compliance (stock, unique keys)
Error handling and validation
📋 Usage Examples:
# Basic seeding
rails db:seed
# Clear data first
CLEAR_DATA=true rails db:seed
# Scale up for testing
# Edit USERS_COUNT in base_seeder.rb then:
rails db:seed
# Stage all the new modular files
git add db/seeds.rb \
db/seeds/
# Commit with comprehensive message
git commit -m "Refactor seed system into modular architecture" -m "
Modular Structure:
- Split monolithic seeds.rb into focused seeder classes
- Create BaseSeeder with shared functionality and configuration
- Separate concerns: each model has dedicated seeder
- Add comprehensive documentation in seeds/README.md
Benefits:
- Maintainability: Easy to modify individual model generation
- Flexibility: Simple to add new models or adjust data
- Robustness: Proper dependency handling and constraint compliance
- Testability: Individual seeders can be tested in isolation
Features:
- Environment-specific behavior (auto-clear in development)
- Smart constraint handling (stock validation, unique keys)
- Progress indicators and comprehensive reporting
- Configurable record counts via constants
Files:
- db/seeds.rb: Main orchestrator (20 lines vs 246)
- db/seeds/base_seeder.rb: Shared functionality & config
- db/seeds/*_seeder.rb: Individual model seeders
- db/seeds/README.md: Complete documentation
The system is now ready for production use and easy to extend."
🎉 What We Can Do Now:
Scale Easily:
# In base_seeder.rb - change these and run rails db:seed
USERS_COUNT = 10_000
PRODUCTS_COUNT = 1_000
ORDERS_COUNT = 50_000
Add New Models: Create new_model_seeder.rb, inherit from BaseSeeder, add to orchestration.
Customize Data: Edit individual seeder constants and attribute methods.
Our seed system is now enterprise-ready with clean architecture, comprehensive documentation, and infinite scalability! 🚀
Possible Refactoring
Now Let’s move the helper methods to a dedicated helper folder for better organization. I’ll create a helpers directory and move the shared utility methods there.
Here’s how to refactor your seed helper methods into a new helpers folder under db/seeds:
Create a directory: db/seeds/helpers/
Move all shared/random data methods (like random_gender, random_category, etc.) into a module, e.g. db/seeds/helpers/data_generators.rb:
# db/seeds/helpers/data_generators.rb
module DataGenerators
def random_gender
['male', 'female', 'not-specified'].sample
end
def random_category
['men', 'women', 'kids', 'infants'].sample
end
def random_date_of_birth
Date.today - rand(18..80).years - rand(365).days
end
def random_phone
"+1#{rand(100..999)}-#{rand(100..999)}-#{rand(1000..9999)}"
end
def random_price
[9.99, 19.99, 29.99, 49.99, 99.99, 199.99, 299.99, 499.99].sample
end
def random_stock
[0, 5, 10, 25, 50, 100, 500].sample
end
def random_past_date(days_ago_max = 365)
Date.today - rand(1..days_ago_max).days
end
end
In your seeders and base seeder, require and include the helper:
# At the top of each seeder or in base_seeder.rb
require_relative 'helpers/data_generators'
class UsersSeeder < BaseSeeder
extend DataGenerators
# ... now you can call random_gender, random_category, etc.
end
Remove the helper methods from BaseSeeder and use the module instead.
Summary:
Move all shared/random data methods to db/seeds/helpers/data_generators.rb as a module.
require_relative and extend or include the module in your seeders.
This keeps your seed logic clean and DRY!
Our BaseSeeder still contains all the random data and progress helper methods, which are now in db/seeds/helpers/data_generators.rb and db/seeds/helpers/ui_helpers.rb.
Here’s what we should do:
Remove all the methods at the bottom of BaseSeeder that are now in the helpers.
At the top of base_seeder.rb, require the helper modules.
If you want to use the helpers as class methods, use extend for each module.
Removed the hardcoded print_summary and print_verification methods.
Ensured print_summary_box and print_verification_stats are ready to accept data.
In BaseSeeder:
Now dynamically creates a summary_data hash with the final record counts.
Creates a verification_stats hash with key metrics.
Calls UiHelpers.print_summary_box and UiHelpers.print_verification_stats, passing in the data.
Benefits of this Refactor:
More Flexible: You can now easily add or remove items from the summary and verification sections just by changing the hashes in BaseSeeder.
Decoupled: The UiHelpers module is now purely for presentation and has no knowledge of your specific models.
Cleaner Code: The logic is now clearly separated—BaseSeeder gathers the data, and UiHelpers displays it.
Now the BaseSeeder is clean and only contains orchestration logic and configuration. All random data and UI/progress helpers are now properly delegated to the new helper modules.
We can now use these helpers in any seeder by calling random_gender, progress_indicator, etc., thanks to the extend statements. This makes our seed system much more modular, maintainable, and DRY!
That’s a great suggestion for further refactoring. It makes sense to keep all data-related logic, including aggregation for summaries, within the DataGenerators helper.
Let’s proceed. I’ll move the hash construction into new methods inside db/seeds/helpers/data_generators.rb and then update BaseSeeder to call them. Now that the data generation has been moved, I’ll update the BaseSeeder to call the new methods.
Issues Fixed:
UiHelpers method calls: All seeder files were calling progress_indicator directly, but since the methods in UiHelpers are defined as class methods, they needed to be prefixed with UiHelpers.. Fixed in:
UsersSeeder
ProductsSeeder
CoursesSeeder
SchoolsSeeder
OrdersSeeder
StudentsSeeder
DataGenerators references: The BaseSeeder was trying to call DataGenerators.summary_data and DataGenerators.verification_stats, but that file was deleted. I updated it to use the local summary_data and verification_stats methods that were already defined in the BaseSeeder class.
Seeding Results:
✅ 10,000 users created
✅ 1,000 products created
✅ 50 courses created
✅ 25 schools created
✅ 5,000 orders created
✅ 5,000 students created
The verification stats show proper data relationships:
4,004 users have orders
647 products have been ordered
756 products are active
200 students per school (average)
100 students per course (average)
✅ Final Architecture:
DataGenerators: Is now responsible for all data-related tasks, including generating random primitive data (random_phone) and creating aggregated summary data (summary_data, verification_stats).
UiHelpers: Is responsible for all presentation logic, taking data as input and printing it to the console in a formatted way.
Individual Seeders (UsersSeeder, etc.): Responsible for the business logic of creating a specific type of record, using helpers for data and UI.
BaseSeeder: The main orchestrator. It knows the correct order to call the individual seeders and delegates all data and UI tasks to the appropriate helpers.
seeds.rb: The single entry point that kicks off the entire process.
Let’s create a Rails 8 app which use SQL queries with raw SQL instead of ActiveRecord. Let’s use the full Rails environment with ActiveRecord for infrastructure, but bypass AR’s ORM features for pure SQL writing. Let me guide you through this step by step:
Step 1: Create the Rails App with ActiveRecord and PostgreSQL (skipping unnecessary components)
rails new academic-sql-software --database=postgresql --skip-action-cable --skip-jbuilder --skip-solid --skip-kamal
What we’re skipping and why:
–skip-action-cable: No WebSocket functionality needed
–skip-jbuilder: No JSON API views needed for our SQL practice app
–skip-solid: Skips Solid Cache and Solid Queue (we don’t need caching or background jobs)
–skip-kamal: No deployment configuration needed
What we’re keeping:
ActiveRecord: For database connection management and ActiveRecord::Base.connection.execute()
ActionController: For creating web interfaces to display our SQL query results
ActionView: For creating simple HTML pages to showcase our SQL learning exercises
PostgreSQL: Our database for practicing advanced SQL features
Why this setup is perfect for App with raw SQL:
Minimal Rails app focused on database interactions
Full Rails environment for development conveniences
ActiveRecord infrastructure without ORM usage
Clean setup without unnecessary overhead
=> Open config/application.rb and comment the following for now:
Development Tools: Access to Rails console for testing queries, database tasks, and debugging
Our Learning Strategy: We’ll use ActiveRecord’s infrastructure but completely bypass its ORM methods. Instead of Student.where(), we’ll use ActiveRecord::Base.connection.execute("SELECT * FROM students WHERE...")
Step 2: Navigate to the project directory
cd academic-sql-software
Step 3: Verify PostgreSQL setup
# Check if PostgreSQL is running
brew services list | grep postgresql
# or
pg_ctl status
Database Foundation: PostgreSQL gives us advanced SQL features:
Complex JOINs (INNER, LEFT, RIGHT, FULL OUTER)
Window functions (ROW_NUMBER, RANK, LAG, LEAD)
Common Table Expressions (CTEs)
Advanced aggregations and subqueries
Step 4: Install dependencies
bundle install
What this gives us:
pg gem: Pure PostgreSQL adapter (already included with --database=postgresql)
✗ rails db:create
Created database 'academic_sql_software_development'
Created database 'academic_sql_software_test
Our Development Environment:
Creates academic_sql_software_development and academic_sql_software_test
Sets up connection pooling and management
Enables us to use Rails console for testing queries: rails console then ActiveRecord::Base.connection.execute("SELECT 1")
Our Raw SQL Approach:
# We'll use this pattern throughout our app:
connection = ActiveRecord::Base.connection
result = connection.execute("SELECT s.name, t.subject FROM students s INNER JOIN teachers t ON s.teacher_id = t.id")
Why not pure pg gem:
Would require manual connection management
No Rails integration (no console, no rake tasks)
More boilerplate code for connection handling
Loss of Rails development conveniences
Why not pure ActiveRecord ORM:
We want to do SQL query writing, not ActiveRecord methods.
Need to understand database performance implications.
Want to practice complex queries that might be harder to express in ActiveRecord.
Step 6: Create Users table
mkdir -p db/migrate
class CreateUsers < ActiveRecord::Migration[8.0]
def up
# create users table
execute <<~SQL
CREATE TABLE users (
id INT,
username VARCHAR(200),
email VARCHAR(150),
phone_number VARCHAR(20)
);
SQL
end
def down
execute <<~SQL
DROP TABLE users;
SQL
end
end
class CreateOrders < ActiveRecord::Migration[8.0]
def up
# create table orders
execute <<~SQL
SQL
end
def down
execute <<~SQL
SQL
end
end
execute <<~SQL is a Rails migration method that allows you to run raw SQL statements. Let me break it down:
Components:
execute – A Rails migration method that executes raw SQL directly against the database
class SomeMigration < ActiveRecord::Migration[8.0]
def change
execute <<~SQL
CREATE INDEX CONCURRENTLY idx_users_email_lower
ON users (LOWER(email));
SQL
end
end
Why use it?
Database-specific features: When you need PostgreSQL-specific syntax, MySQL features, etc.
Complex SQL: For operations that Rails DSL doesn’t support easily
Performance: Direct SQL can sometimes be more efficient
Raw SQL requirements: When you specifically need to write SQL (as mentioned in our requirement)
Example for creating a users table with raw SQL:
class CreateUsers < ActiveRecord::Migration[8.0]
def up
execute <<~SQL
CREATE TABLE users (
id BIGSERIAL PRIMARY KEY,
name VARCHAR(255) NOT NULL,
email VARCHAR(255) NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE UNIQUE INDEX idx_users_email ON users(email);
SQL
end
def down
execute <<~SQL
DROP TABLE IF EXISTS users;
SQL
end
end
The <<~SQL heredoc ends when it encounters the closing SQL keyword, and the ~strips indentation so your SQL can be properly formatted within the Ruby code.
Q) 🤔 Is it a best practise to repeat the database constraints in model validation in Rails? DB level vs App level? or both?
Best Practice: Use BOTH 🎯
Why both levels are recommended:
Database Level (Last Line of Defense)
-- Database constraints
price DECIMAL(10,2) NOT NULL CHECK (price > 0),
category product_category NOT NULL,
title VARCHAR(250) NOT NULL
Pros:
✅ Data integrity guarantee – No bad data can enter, ever
✅ User-friendly errors – “Price must be greater than 0” vs “CHECK constraint violated”
✅ Better UX – Validation before form submission
✅ Easier testing – Can unit test validations
✅ Conditional logic – Complex business rules
✅ Framework features – Callbacks, custom validators
Real-world scenarios where each matters:
Database saves you when:
# Bulk operations bypass Rails validations
Product.update_all(price: -10) # DB constraint prevents this
# Direct SQL injection attempts
# DB constraints are your last line of defense
App validations save you when:
# User gets friendly error instead of:
# PG::CheckViolation: ERROR: new row violates check constraint
@product = Product.new(price: -5)
@product.valid? # => false
@product.errors.full_messages # => ["Price must be greater than 0"]
Practical Implementation:
class Product < ApplicationRecord
# App-level validations for UX
validates :title, presence: true, length: { maximum: 250 }
validates :price, presence: true, numericality: { greater_than: 0 }
validates :category, inclusion: { in: %w[men women kids infants] }
# Don't duplicate precision validation if DB handles it
# The DECIMAL(10,2) constraint is sufficient at DB level
end
-- DB-level constraints for data integrity
CREATE TABLE products (
id BIGSERIAL PRIMARY KEY,
title VARCHAR(250) NOT NULL,
price DECIMAL(10,2) NOT NULL CHECK (price > 0),
category product_category NOT NULL,
-- DB handles precision automatically with DECIMAL(10,2)
);
What NOT to duplicate:
❌ Precision constraints – DECIMAL(10,2) handles this perfectly
❌ Data type validation – DB enforces INTEGER, BOOLEAN, etc.
❌ Complex regex patterns – Better handled in app layer
Conclusion:
Use both, but strategically:
Database: Core data integrity, type constraints, foreign keys
Application: User experience, business logic, conditional rules
Don’t over-duplicate simple type/precision constraints that DB handles well
This approach gives you belt and suspenders protection with optimal user experience.
Here’s a list of commonly used SQL comparison operators with brief explanations and examples:
📋 Basic Comparison Operators:
Operator
Meaning
Example
Result
=
Equal to
WHERE age = 25
Matches rows where age is 25
<>
Not equal to (standard)
WHERE status <> 'active'
Matches rows where status is not 'active'
!=
Not equal to (alternative)
WHERE id != 10
Same as <>, matches if id is not 10
>
Greater than
WHERE salary > 50000
Matches rows with salary above 50k
<
Less than
WHERE created_at < '2024-01-01'
Matches dates before Jan 1, 2024
>=
Greater than or equal
WHERE age >= 18
Matches age 18 and above
<=
Less than or equal
WHERE age <= 65
Matches age 65 and below
📋 Other Common Operators:
Operator
Meaning
Example
BETWEEN
Within a range
WHERE price BETWEEN 100 AND 200
IN
Match any value in a list
WHERE country IN ('US', 'CA', 'UK')
NOT IN
Not in a list
WHERE role NOT IN ('admin', 'staff')
IS NULL
Value is null
WHERE deleted_at IS NULL
IS NOT NULL
Value is not null
WHERE updated_at IS NOT NULL
LIKE
Pattern match (case-insensitive in some DBs)
WHERE name LIKE 'J%'
ILIKE
Case-insensitive LIKE (PostgreSQL only)
WHERE email ILIKE '%@gmail.com'
Now we’ve our products and product_variants schema, let’s re-explore all major SQL JOINs using these two related tables.
####### Products
Column | Type | Collation | Nullable | Default
-------------+--------------------------------+-----------+----------+--------------------------------------
id | bigint | | not null | nextval('products_id_seq'::regclass)
description | text | | |
category | character varying | | |
created_at | timestamp(6) without time zone | | not null |
updated_at | timestamp(6) without time zone | | not null |
name | character varying | | not null |
rating | numeric(2,1) | | | 0.0
brand | character varying | | |
######## Product variants
Column | Type | Collation | Nullable | Default
------------------+--------------------------------+-----------+----------+----------------------------------------------
id | bigint | | not null | nextval('product_variants_id_seq'::regclass)
product_id | bigint | | not null |
sku | character varying | | not null |
mrp | numeric(10,2) | | not null |
price | numeric(10,2) | | not null |
discount_percent | numeric(5,2) | | |
size | character varying | | |
color | character varying | | |
stock_quantity | integer | | | 0
specs | jsonb | | not null | '{}'::jsonb
created_at | timestamp(6) without time zone | | not null |
updated_at | timestamp(6) without time zone | | not null |
💎 SQL JOINS with products and product_variants
These tables are related through:
product_variants.product_id → products.id
So we can use that for all join examples.
🔸 1. INNER JOIN – Show only products with variants
SELECT
p.name,
pv.sku,
pv.price
FROM products p
INNER JOIN product_variants pv ON p.id = pv.product_id;
♦️ Only returns products that have at least one variant.
🔸 2. LEFT JOIN – Show all products, with variants if available
SELECT
p.name,
pv.sku,
pv.price
FROM products p
LEFT JOIN product_variants pv ON p.id = pv.product_id;
♦️ Returns all products, even those with no variants (NULLs in variant columns).
🔸 3. RIGHT JOIN – Show all variants, with product info if available
(Less common, but useful if variants might exist without a product record)
SELECT
pv.sku,
pv.price,
p.name
FROM products p
RIGHT JOIN product_variants pv ON p.id = pv.product_id;
🔸 4. FULL OUTER JOIN – All records from both tables
SELECT
p.name AS product_name,
pv.sku AS variant_sku
FROM products p
FULL OUTER JOIN product_variants pv ON p.id = pv.product_id;
♦️ Shows all products and all variants, even when there’s no match.
🔸 5. SELF JOIN Example (for product_variants comparing similar sizes or prices)
Let’s compare variants of the same product that are different sizes.
SELECT
pv1.product_id,
pv1.size AS size_1,
pv2.size AS size_2,
pv1.sku AS sku_1,
pv2.sku AS sku_2
FROM product_variants pv1
JOIN product_variants pv2
ON pv1.product_id = pv2.product_id
AND pv1.size <> pv2.size
WHERE pv1.product_id = 101; -- example product
♦️ Useful to analyze size comparisons or price differences within a product.
🧬 Complex Combined JOIN Example
Show each product with its variants, and include only discounted ones (price < MRP):
SELECT
p.name AS product_name,
pv.sku,
pv.price,
pv.mrp,
(pv.mrp - pv.price) AS discount_value
FROM products p
INNER JOIN product_variants pv ON p.id = pv.product_id
WHERE pv.price < pv.mrp
ORDER BY discount_value DESC;
📑 JOIN Summary with These Tables
JOIN Type
Use Case
INNER JOIN
Only products with variants
LEFT JOIN
All products, even if they don’t have variants
RIGHT JOIN
All variants, even if product is missing
FULL OUTER JOIN
Everything — useful in data audits
SELF JOIN
Compare or relate rows within the same table
Let’s now look at JOIN queries with more realistic conditions using products and product_variants.
🦾 Advanced JOIN Queries with Conditions to practice
🔹 1. All products with variants in stock AND discounted
SELECT
p.name AS product_name,
pv.sku,
pv.size,
pv.color,
pv.stock_quantity,
pv.mrp,
pv.price,
(pv.mrp - pv.price) AS discount_amount
FROM products p
JOIN product_variants pv ON p.id = pv.product_id
WHERE pv.stock_quantity > 0
AND pv.price < pv.mrp
ORDER BY discount_amount DESC;
♦️ Shows available discounted variants, ordered by discount.
🔹 2. Products with high rating (4.5+) and at least one low-stock variant (< 10 items)
SELECT
p.name AS product_name,
p.rating,
pv.sku,
pv.stock_quantity
FROM products p
JOIN product_variants pv ON p.id = pv.product_id
WHERE p.rating >= 4.5
AND pv.stock_quantity < 10;
🔹 3. LEFT JOIN to find products with no variants or all variants out of stock
SELECT
p.name AS product_name,
pv.id AS variant_id,
pv.stock_quantity
FROM products p
LEFT JOIN product_variants pv
ON p.id = pv.product_id AND pv.stock_quantity > 0
WHERE pv.id IS NULL;
✅ This tells you:
Either the product has no variants
Or all variants are out of stock
🔹 4. Group and Count Variants per Product
SELECT
p.name AS product_name,
COUNT(pv.id) AS variant_count
FROM products p
LEFT JOIN product_variants pv ON p.id = pv.product_id
GROUP BY p.name
ORDER BY variant_count DESC;
🔹 5. Variants with price-percentage discount more than 30%
SELECT
p.name AS product_name,
pv.sku,
pv.mrp,
pv.price,
ROUND(100.0 * (pv.mrp - pv.price) / pv.mrp, 2) AS discount_percent
FROM products p
JOIN product_variants pv ON p.id = pv.product_id
WHERE pv.price < pv.mrp
AND (100.0 * (pv.mrp - pv.price) / pv.mrp) > 30;
🔹 6. Color-wise stock summary for a product category
SELECT
p.category,
pv.color,
SUM(pv.stock_quantity) AS total_stock
FROM products p
JOIN product_variants pv ON p.id = pv.product_id
WHERE p.category = 'Shoes'
GROUP BY p.category, pv.color
ORDER BY total_stock DESC;
These queries simulate real-world dashboard views: inventory tracking, product health, stock alerts, etc.
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.
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.
In this post let’s find out how the data structure look like for a b-tree index in postgresql. Also we analyse our following test query results using EXPLAIN ANALYSE
EXPLAIN ANALYSE SELECT * FROM users WHERE city='Chicago';
QUERY PLAN ----- Seq Scan on users (cost=0.00..2332.00 rows=24780 width=51)
(actual time=0.077..21.085 rows=25000 loops=1)
CREATE INDEX idx_users_city ON users(city);
CREATE INDEX EXPLAIN ANALYSE SELECT * FROM users WHERE city='Chicago';
QUERY PLAN ---- Bitmap Heap Scan on users (cost=280.34..1672.09 rows=24780 width=51)
(actual time=3.309..15.732 rows=25000 loops=1)
Q1) How can PostgreSQL build a B-tree index for emails when every email is unique?
Short answer: Yes. The index contains one entry for every row.
The tree lets PostgreSQL eliminate huge portions of the search space.
Instead of checking every row, it follows the correct branch.
Does it consume memory?
Yes.
Every index consumes disk space.
If you have:
1 million rows
and create an index on email,
the index also has approximately 1 million entries.
That’s why we don’t create indexes on everything.
What happens during INSERT?
Suppose:
INSERT INTO users(email)
VALUES ('zack@test.com');
PostgreSQL does two things:
Inserts the row into the table.
Inserts a new entry into the B-tree.
That’s why indexes make:
INSERT
UPDATE
DELETE
slightly slower.
Interview Question
If an index has one entry per row, isn’t searching still O(n)?
No.
Because of the B-tree.
Searching isn’t done linearly.
It’s approximately:
O(log n)
instead of
O(n)
For:
1,000,000 rows
a B-tree may require only around 20–25 comparisons rather than scanning all million rows.
Q2. Why did PostgreSQL use a Bitmap Heap Scan instead of an Index Scan?
Your output:
Before index:
Seq Scan on users
rows = 25000
After index:
Bitmap Heap Scan
rows = 25000
This is actually exactly what PostgreSQL should do.
Let’s understand why.
Your data distribution
Remember how you inserted the data?
CASE
WHEN i % 4 = 0 THEN 'Boston'
WHEN i % 4 = 1 THEN 'Chicago'
WHEN i % 4 = 2 THEN 'New York'
ELSE 'Dallas'
END
So:
100,000 rows
↓
4 cities
↓
25,000 users per city
That means:
Chicago
↓
25%
of the table
Option 1 — Sequential Scan
Without index:
Read
100000 rows
↓
Return
25000 rows
One pass through the table.
Option 2 — Normal Index Scan
Imagine PostgreSQL used the city index.
It would do something like:
Index
↓
Find row 4
↓
Jump to table
↓
Find row 9
↓
Jump to table
↓
Find row 13
↓
Jump to table
...
25000 times
That’s a lot of random table accesses.
Random disk reads (or random memory accesses) are expensive.
Option 3 — Bitmap Heap Scan
This is PostgreSQL’s compromise.
Step 1:
Read the index.
Chicago
↓
Rows
4
9
13
22
31
...
99998
Instead of fetching the rows immediately, PostgreSQL creates a bitmap.
Conceptually:
Rows to fetch
4
9
13
22
31
...
Then it sorts/groups those row locations by table page.
Only then does it read the table.
So instead of:
Index
↓
Table
↓
Index
↓
Table
↓
Index
↓
Table
it does:
Index
↓
Collect all matching row locations
↓
Read table pages efficiently
↓
Return rows
This reduces random I/O significantly.
When does PostgreSQL choose Bitmap Heap Scan?
Typically when:
Some rows match
but
not too few
and
not almost all.
Think of it like this:
Rows matched
Likely plan
1 row
Index Scan
100 rows
Index Scan
5,000 rows
Bitmap Heap Scan
25,000 rows
Bitmap Heap Scan
99,000 rows
Seq Scan
The exact thresholds depend on statistics and cost estimates.
Why not an Index Scan?
Your query returns:
25,000 rows
That’s 25% of the table.
PostgreSQL thinks:
“Using the index is worthwhile, but fetching 25,000 rows one-by-one would be inefficient. I’ll gather all matching row locations first and then fetch the data in batches.”
That’s why you got:
Bitmap Heap Scan
Understanding our EXPLAIN ANALYZE Output
Seq Scan on users
(cost=0.00..2332.00 rows=24780 width=51)
(actual time=0.077..21.085 rows=25000 loops=1)
Let’s decode it.
Seq Scan
PostgreSQL reads every row.
cost
0.00..2332.00
This is not time.
It’s PostgreSQL’s internal cost estimate.
0.00 = startup cost
2332.00 = estimated total cost
Costs are used only to compare execution plans.
rows=24780
Planner estimated:
24,780 rows
Actual:
25,000 rows
Excellent estimate.
Good statistics help PostgreSQL choose the right plan.
width=51
Average row size is estimated to be:
51 bytes
This helps estimate I/O cost.
actual time
0.077..21.085
First row available after 0.077 ms.
Entire query finished after 21.085 ms.
loops=1
The node executed once.
After Creating the Index
Bitmap Heap Scan
(actual time=3.309..15.732)
Notice:
Execution time dropped from roughly:
21 ms
↓
16 ms
The improvement isn’t dramatic because your query still returns 25% of the table.
Indexes shine when they allow PostgreSQL to skip most of the table.
Want to See an Index Scan?
Try a highly selective query.
EXPLAIN ANALYZE
SELECT *
FROM users
WHERE email = 'user75000@example.com';
Since email is unique, PostgreSQL should choose:
Index Scan
because only one row matches.
A Practical Rule for Senior Engineers
When reading an execution plan, ask yourself these questions in order:
How many rows does the query return?
How many rows are in the table?
Is the predicate selective enough for an index?
What scan type did PostgreSQL choose?
Does that choice make sense?
Let’s cover the following topics in the remaining areas of Day 6.
Day 6B – Composite indexes, covering indexes, partial indexes, unique indexes, expression indexes, GIN vs GiST vs BRIN vs Hash indexes, and real-world Rails indexing strategies
Day 6C – Query optimization workshop: we’ll analyze real EXPLAIN ANALYZE outputs together, identify bottlenecks, and optimize queries step by step.
Given our role of Senior Rails Developer, Let’s spend 3 focused sessions on indexing and query optimization will provide much more value than rushing to the next topic.