Rails database performance
Skill mickzijdel/rails-toolkit/skills/rails-database-performance
Agent skills for working with Ruby on Rails 8+
npx -y skills add mickzijdel/rails-toolkit --skill rails-database-performanceAssembled from the repository path, not quoted from the project. Check it against their README if it does not work.
One thing to look at
- 0 stars0 stars. Stars are a popularity signal and not a quality one, but at this level it is likely that nobody has read this closely except its author, and you would be relying on your own review.
What its author says it does
Copied from the file, not written here
Use when reviewing or auditing a Rails app's database schema for missing indexes, slow query patterns, or database performance issues. Triggers on: schema review, slow queries, EXPLAIN ANALYZE output, missing index warnings, or any request to audit db/schema.rb.
SKILL.md
8.5 KB, as published. Nobody here has run it
Rails Database Index & Query Performance Audit
Overview
A systematic checklist for auditing db/schema.rb and ActiveRecord models for missing indexes and query anti-patterns. Work through every section below — do not stop early.
How to Run This Audit
- Open
db/schema.rb - Open all model files (
app/models/**/*.rb) - Work through every checklist item below
- For each issue found: generate a migration, do not bundle unrelated indexes together
Checklist
1. Foreign Key Indexes
Every column ending in _id must have an index.
grep -n "_id" db/schema.rb | grep -v "index\|#"
Cross-reference with:
grep -n "add_index\|t\.index" db/schema.rb | grep "_id"
Fix:
add_index :table_name, :other_model_id
2. Polymorphic Association Indexes
Any polymorphic association (_type + _id pair) needs a composite index on both columns together, not two separate indexes.
grep -n "_type" db/schema.rb
Fix:
add_index :comments, [:commentable_type, :commentable_id]
3. Frequently Queried Scope Columns
Check every model for scopes using where. Each column in a where clause on a frequently-called scope is a candidate for an index.
grep -rn "scope.*where" app/models/
Common patterns that need indexes:
where(published: true)→ index onpublishedwhere(featured: true)→ index onfeaturedwhere(active: true)→ index onactivewhere(status: ...)→ index onstatuswhere(account_id: ...)+ another column → consider composite index
Fix:
add_index :articles, :published
add_index :articles, :featured
4. Status / State Columns
Columns named status, state, aasm_state, workflow_state that are queried with where need an index. These are often used in scopes like where(status: "published").
grep -n "status\|state\|aasm_state" db/schema.rb
Check models for transitions and scopes on these columns.
Fix:
add_index :posts, :status
5. Sort / Order Columns
Any column used in ORDER BY should have an index so the database can read pre-sorted data instead of sorting at query time.
grep -rn "\.order(" app/models/ app/controllers/
Common columns: created_at, updated_at, position, published_at, name.
Already indexed by default in many Rails setups: created_at, updated_at — verify they're actually present.
Fix:
add_index :articles, :published_at
add_index :items, :position
For multi-column sorts, include sort direction:
add_index :articles, [:account_id, :created_at]
6. Position / Sortable List Columns
Columns named position or sort_order for drag-and-drop ordered lists need an index.
grep -n "position\|sort_order\|rank" db/schema.rb
7. Login / Authentication Columns
Any column used to look up users during authentication must have a unique index.
grep -rn "find_by.*email\|find_by.*username\|where.*email\|where.*username" app/models/
Check: email_address, email, username, token, reset_password_token, confirmation_token
Fix:
add_index :users, :email_address, unique: true
add_index :users, :username, unique: true
8. Counter Cache Columns
*_count columns added by counter_cache: true should have no index (they're read, not searched), but verify the parent model declares counter_cache: true.
grep -n "_count" db/schema.rb
grep -rn "counter_cache" app/models/
If you find a count > 0 or .count call on a large association without a counter cache, consider adding one:
belongs_to :project, counter_cache: true
9. Counting Anti-Pattern
Model.count performs a full table scan. Check controllers and models for count calls on large tables.
grep -rn "\.count\b" app/models/ app/controllers/ app/helpers/
Replace:
items.count > 0→items.exists?items.count == 0→items.none?or!items.exists?- Frequently displayed counts →
counter_cache
10. Offset Pagination Anti-Pattern
LIMIT/OFFSET pagination degrades linearly — page 100 is ~100x slower than page 1.
grep -rn "\.offset\|paginate\|page(" app/models/ app/controllers/
Fix: Use keyset/cursor pagination, e.g. with geared_pagination (see [[rails-performance]]):
@page = set_page_and_extract_portion_from(scope, per_page: [15, 30, 50])
Or use range-based queries:
Fault.where("created_at > ? AND created_at < ?", 100.days.ago, 101.days.ago)
11. Sorting Without Indexes
Any ORDER BY on an unindexed column causes the database to sort the full result set in memory.
Run EXPLAIN ANALYZE on your slowest queries to spot sequential scans on large tables with sorts:
EXPLAIN ANALYZE SELECT * FROM faults ORDER BY created_at DESC LIMIT 25;
Look for: Sort Method: external merge Disk or Seq Scan — these indicate missing indexes.
12. Unbounded Append-Only Tables
Audit/versioning/logging tables grow forever by design and quietly become the largest tables in the database — slowing backups, bloating the buffer pool (hurting cache hit rates for the data that matters), and dragging out migrations.
grep -nE 'create_table "(versions|audits|.*_logs|.*_events|page_views|ahoy_)' db/schema.rb
grep -rn "paper_trail\|has_paper_trail\|audited" Gemfile app/models/ | head
For any hit, check whether anything bounds growth (cron/recurring job deleting old rows).
Fix (in order of preference):
- Retention policy — a recurring job that deletes rows older than the period anyone actually looks at:
PaperTrail::Version.where("created_at < ?", 1.year.ago).in_batches.delete_all - Move to a separate database via Rails multi-db. Audit data is a good fit: read rarely (admin-only), no performance-sensitive JOINs against it, and "best-effort" consistency is acceptable. Shrinks the primary, speeds up backups and upgrades.
Capacity Metrics & Monitoring
Two numbers tell you whether the database is the bottleneck before users do:
- Active (non-idle) connections to the primary — healthy target is ≤ ~50% of the database's CPU count. Sustained levels above that mean queries are queueing for CPU; more app servers will make it worse, not better.
- Per-controller p95 response time — aim for < 1 second on the top-traffic controllers. The overall average hides the handful of endpoints doing all the damage; rank controllers by p95 × throughput.
Install a database monitoring tool — index suggestions, captured EXPLAIN plans, and connection/CPU history beat re-running EXPLAIN ANALYZE by hand:
| Tool | Notes |
|---|---|
| pghero | Free, Rails-friendly, Postgres |
| pganalyze | Paid, Postgres, the most complete |
| Percona PMM | Open source, the strong MySQL option |
| Datadog DBM | Paid add-on if already on Datadog |
Generating Migrations
For each group of related indexes, generate a descriptive migration:
rails generate migration AddMissingIndexesToArticles
rails generate migration AddPolymorphicIndexToComments
rails generate migration AddAuthIndexesToUsers
Keep each migration focused. Do not combine unrelated tables in one migration.
Quick Reference
| Column Pattern | Required Index |
|---|---|
*_id (foreign key) | Single index |
*_type + *_id (polymorphic) | Composite index on both |
Scope where columns | Single index |
status, state columns | Single index |
ORDER BY columns | Single index (with direction if needed) |
position, sort_order | Single index |
email, username (login) | Unique index |
*_token (auth tokens) | Unique index |
versions/audit/log tables | Retention policy or separate DB (no index fixes growth) |
Common Mistakes
- Adding separate indexes for polymorphic
_typeand_idinstead of a composite index - Forgetting that
email_addresson theuserstable should be unique - Indexing boolean columns with very low cardinality on small tables (not worth it — only index if the table is large and the
whereis frequent) - Not using
EXPLAIN ANALYZEto verify the index is actually being used after adding it