Active Record Performance skill for Codex
Faster Rails queries, proven with numbers. Codex profiles the slow Rails action or job first, then picks the fix that matches: preload or eager_load for per-row queries, counter caches backfilled in batches for list counts, pluck for read-only data, and in_batches with throttling for mass updates. Index migrations use algorithm: :concurrently with the DDL transaction disabled, and strict_loading keeps lazy loads out of new code.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- active-record-performance Installs to ~/.agents/skills/active-record-performance/ for every project on this machine.
curl -fsSL codexskills.dev/cli.mjs -o codex-skills.mjsnode codex-skills.mjs install active-record-performance The CLI also searches, updates and removes skills. Run it with no arguments to pick from a list. Needs Node 20.12+.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- --project active-record-performance Installs to .agents/skills/active-record-performance/ in the current repository. Commit it and everyone who runs Codex there gets it.
curl -fsSLO codexskills.dev/downloads/active-record-performance.zipunzip -o active-record-performance.zip -d ~/.agents/skills/ The archive holds the skill folder with its generated agents/openai.yaml. Unzip into .agents/skills/ to scope it to a repo.
& ([scriptblock]::Create((irm codexskills.dev/install.ps1))) active-record-performance PowerShell 5.1 or 7. Installs to %USERPROFILE%\.agents\skills\. Add -Project for the current repo.
104-line POSIX script. No sudo, no telemetry. Read install.sh
Use it in Codex
Start a prompt with $active-record-performance, or pick it from /skills.
You can also just describe the task: Codex reads the description below and loads the playbook when your request matches.
What Codex reads before loading it
Tunes Rails Active Record code from measurements - includes, preload and eager_load choices, strict_loading, counter caches and backfilling them, select and pluck, exists? versus present?, find_each and in_batches, insert_all and upsert_all, update_all with SQL expressions, load_async, explain with analyze, indexes in migrations with algorithm concurrently, and connection pool sizing for Puma and Sidekiq. Use when a Rails controller, view, serializer, job or rake task is slow, memory-heavy or query-heavy, when counts render slowly, or when a migration adds an index to a large table.
Use it when
- A Rails controller, serializer or view makes many queries or is slow
- Counts, sums or existence checks dominate a page
- A rake task or Sidekiq job processes large tables
- A migration adds an index or counter cache to a big table
Use something else when
- Broader Rails 8 app structure, Hotwire or Solid Queue setup: use rails-apps
- MySQL tables too large for plain online DDL: use mysql-online-schema-change
How to check the result
Query count is flat across two data sizes, bullet or prosopite stays silent with strict_loading on in tests, and explain(:analyze) shows the intended index on the slowest remaining query.
The mistake it prevents
Using update_all, insert_all or delete_all and forgetting that they skip callbacks and counter caches, leaving counts and search indexes stale.
Done well means: the slow action or job has a measured baseline (query count, SQL time,
allocations), each change targets the measured cause, lazy loading is caught by
strict_loading in development and test, and large-table index changes never block writes.
Workflow
- Read
AGENTS.md,Gemfile.lock(Rails version,pg/mysql2/trilogy,bullet,prosopite,rack-mini-profiler) andconfig/database.yml. - Reproduce in development or a test with realistic data volume.
- Measure (table below). Note query count, top statements and allocations.
- Classify: per-row queries, one slow query, too many columns or rows loaded, or Ruby work per row. Fix that cause only.
- Re-measure; add a guard (strict loading or a query-count assertion).
| Need | Tool |
|---|---|
| Per-request queries and timing in dev | rack-mini-profiler; development log with config.active_record.verbose_query_logs = true |
| N+1 detection | bullet or prosopite in dev/test; strict_loading |
| Plan of a relation | Order.where(...).explain (Rails 7.1+: .explain(:analyze, :buffers) on Postgres) |
| Attribute queries to code in production | config.active_record.query_log_tags_enabled = true (comments in SQL) |
| Count queries in a test | subscribe to sql.active_record via ActiveSupport::Notifications.subscribed |
explain(:analyze) executes the query; run it against development or a replica.
Loading associations
| Method | SQL | Use when |
|---|---|---|
preload(:items) | Separate IN query per association | Default for has_many; no filtering on the association |
eager_load(:customer) | One LEFT OUTER JOIN | You filter or order by the association’s columns |
includes(:items) | Preload, or eager_load if referenced in where/references | Convenience; be explicit in hot paths |
orders = Order.where(account: current_account)
.preload(:customer, line_items: :product)
.order(created_at: :desc)
.limit(50)
- Turn on strict loading so missed preloads raise in dev and test:
# config/environments/development.rb and test.rb
config.active_record.strict_loading_by_default = true
config.active_record.action_on_strict_loading_violation = :raise
# Or per model / per relation: self.strict_loading_by_default = true; Order.strict_loading
record.strict_loading!(mode: :n_plus_one_only) limits violations to real N+1
patterns for that record. Roll out strict loading model by model on large apps.
- Scoped associations:
has_many :active_items, -> { where(active: true) }, class_name: "Item"and preload that, instead of calling.whereon a preloaded association (new query per row). - Deep N+1 playbook across ORMs:
$n-plus-one-queries.
Counts
sizeuses the loaded collection or a counter cache;countalways runsCOUNT(*);lengthloads all records. In views, usesize.- Counter caches for counts rendered in lists:
class Comment < ApplicationRecord
belongs_to :post, counter_cache: true
end
Adding one to an existing table: migrate the column with default: 0, null: false, then
backfill in batches before reads rely on it. Rails 7.2+ supports
counter_cache: { active: false } so writes maintain the column while reads still count,
then flip to true after the backfill.
Post.in_batches(of: 1_000) do |batch|
batch.update_all(<<~SQL.squish)
comments_count = (SELECT COUNT(*) FROM comments WHERE comments.post_id = posts.id)
SQL
end
- Counter caches skip
update_all,delete_allandinsert_all; code paths using them must adjust counts themselves. Grouped counts for a page:Comment.where(post_id: ids).group(:post_id).count.
Load less
pluck(:id, :email)returns arrays without instantiating models;pick(:email)for one row.select(:id, :name)keeps model objects with fewer columns; accessing an unselected attribute raisesActiveModel::MissingAttributeError.exists?runsSELECT 1 ... LIMIT 1;present?/any?on an unloaded relation may load rows.- Large text, JSON or blob columns in list queries:
selectthem out, or move to a side table.
Batches and bulk writes
User.where(active: true).find_each(batch_size: 1_000) { |u| SyncJob.perform_later(u.id) }
Order.where(status: "stale").in_batches(of: 5_000) do |rel|
rel.update_all(status: "archived", updated_at: Time.current)
sleep 0.05 # throttle on busy primaries
end
Product.upsert_all(rows, unique_by: %i[account_id sku], record_timestamps: true)
find_eachorders by primary key and ignores yourorder; usein_batches(cursor:)(Rails 8+) or keyset loops when another order matters.insert_all,upsert_all,update_all,delete_allskip validations and callbacks. Confirm nothing (search indexing, auditing, counter caches) depends on them.- Mass
delete_all/update_allon production data require explicit user confirmation and a dry-run count first (rel.count). - Atomic increments:
Product.where(id: id).update_counters(stock: -1)instead of read-modify-write.
Parallel independent queries
load_async runs independent queries in a background thread pool (configure
config.active_record.async_query_executor = :global_thread_pool). Use it for 2-4
unrelated slow queries in one action; it does not help a single slow query.
Index migrations
class AddIndexToOrdersOnAccountCreatedAt < ActiveRecord::Migration[8.0]
disable_ddl_transaction!
def change
add_index :orders, [:account_id, :created_at], order: { created_at: :desc },
algorithm: :concurrently, if_not_exists: true
add_index :orders, :created_at, where: "status = 'pending'", name: "index_orders_pending",
algorithm: :concurrently, if_not_exists: true
end
end
algorithm: :concurrently(Postgres) requiresdisable_ddl_transaction!; put nothing else in that migration. A failed concurrent build leaves an INVALID index: drop it and retry.- MySQL: InnoDB adds secondary indexes online by default;
algorithm: :inplacestyle options belong in$mysql-online-schema-changefor big tables. strong_migrationsgem, when present, enforces these rules; follow its messages.referencescolumns get an index by default; a composite index starting with that column can make it redundant.
Connection pool
poolindatabase.ymlmust be at least Pumamax_threads(plusload_asyncthreads) per process, and Sidekiqconcurrencyper Sidekiq process.- Total connections = processes x pool across web and workers; keep it under the
database
max_connectionsor put PgBouncer in front ($connection-pooling).
Gotchas
includeswith awhereon a string condition referencing the association needs.references(:assoc), or Rails preloads and the SQL fails.default_scopewithorderbreaksfind_eachexpectations and adds sorts everywhere.- Callbacks (
after_save) that touch associations turn bulk updates into N queries; useupdate_alldeliberately or move work to a job. touch: truechains cascade writes up the tree on every child save.- Caching (
Rails.cache.fetch) hides slow queries; fix the query first, cache second.
Verification
- Query count and SQL time before and after at two data sizes; count must not grow with rows.
bullet/prosopitesilent and strict loading green across the test suite.explain(:analyze)on the slowest remaining query shows the intended index.- For counter caches: spot-check
Post.find(id).comments_count == Post.find(id).comments.counton a sample after backfill.
Output
**Path:** <controller#action / job / rake task>
**Before:** <queries, SQL ms, allocations> at <data size>
**Cause:** <N+1 / COUNT per row / full-row load / missing index / callback cascade>
**Changes:** <relation changes, counter caches, migrations>
**After:** <queries, SQL ms, allocations>
**Guard:** <strict_loading scope / query-count test> What the zip contains
SKILL.md plus the interface file below. Nothing runs on install.
agents/openai.yaml
Gives the Codex app a display name, a one-line description and a starter prompt for this skill.
interface:
display_name: "Active Record Performance"
short_description: "Faster Rails queries, proven with numbers"
default_prompt: "$active-record-performance Our Rails dashboard renders comment counts per post and it is slow, fix it" About this skill
Written and maintained by the Codex Skills editors to the same editorial standard as every skill here: a selective description, a real workflow, known pitfalls and a way to verify the work. First published , last updated . MIT licensed. Something wrong or out of date? Tell us.