Rails activerecord queries
Skill Tyr0/agent-skills/plugins/rails-expert/skills/rails-activerecord-queries
Use this skill whenever the user asks about writing efficient ActiveRecord queries, avoiding N+1 problems, eager loading associations, batch processing large datasets, bulk insert/update/delete, or query optimization in Rails. Also use it for questions about includes/eager_load/preload differences, pluck, exists?, find_each, update_all, delete_all, to_sql, explain, or the Bullet gem. Triggers on 'how do I avoid N+1', 'what is the difference between includes and eager_load', 'how do I batch process records', 'why is my query slow', or any question about making ActiveRecord queries faster or correct.From its SKILL.md
npx -y skills add Tyr0/agent-skills --skill rails-activerecord-queriesAssembled 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.
SKILL.md
8.9 KB, ~2.1k tokens by cl100k_base, as published. Nobody here has run it
ActiveRecord Query Optimization Reference
A dense reference for writing correct, efficient ActiveRecord queries — covering N+1 prevention, batch processing, bulk operations, and query analysis.
N+1 Queries
An N+1 query fires one query to load a collection, then one query per record to load an association — N+1 total queries for N records.
# BAD — fires 1 query for posts + 1 per post for author = N+1
posts = Post.all
posts.each { |p| puts p.author.name }
# GOOD — 2 queries total (posts + users)
posts = Post.includes(:author)
posts.each { |p| puts p.author.name }
includes vs eager_load vs preload
| Method | SQL Strategy | Use when |
|---|---|---|
includes | Rails decides (preload or LEFT JOIN) | Default choice; let Rails optimize |
preload | Always separate queries | Avoid Cartesian product with has_many + conditions |
eager_load | Always LEFT OUTER JOIN | Need to where or order on the association |
# includes — Rails chooses the strategy
Post.includes(:author, :comments)
# eager_load — forces JOIN, required when filtering on the association
Post.eager_load(:author).where(users: { active: true })
# preload — forces separate queries, avoids row duplication on has_many
Post.preload(:comments).where(published: true)
When includes silently becomes eager_load: If you add a where or order referencing the associated table, Rails switches to a JOIN. This can produce duplicate records when loading has_many associations with conditions. Use preload explicitly to force separate queries and avoid the duplication.
Nested associations
Post.includes(comments: :author)
Post.includes(:author, comments: [:author, :likes])
Detecting N+1 with Bullet
# Gemfile
group :development do
gem "bullet"
end
# config/environments/development.rb
config.after_initialize do
Bullet.enable = true
Bullet.rails_logger = true
Bullet.add_footer = true # shows alerts in browser
Bullet.raise = true # raises an error in test
end
Selecting Only Needed Columns
# Avoids instantiating full AR objects — significant memory savings on large tables
User.select(:id, :email, :name).where(active: true)
Use pluck when you only need raw values (skips AR object instantiation entirely):
User.where(active: true).pluck(:id, :email)
# => [[1, "[email protected]"], [2, "[email protected]"]]
User.pluck(:email)
# => ["[email protected]", "[email protected]"]
Use ids as a shorthand for pluck(:id):
Post.where(published: true).ids
Presence Checks
# Bad — fires COUNT(*) and loads the count into Ruby
if User.where(email: email).count > 0
if User.where(email: email).any? # also fires a COUNT in most adapters
# Good — fires SELECT 1 ... LIMIT 1
if User.where(email: email).exists?
Batch Processing Large Datasets
Never load an entire large table into memory. Use batching.
# find_each — yields one record at a time
User.where(active: true).find_each(batch_size: 500) do |user|
user.send_weekly_digest
end
# find_in_batches — yields arrays of records
User.find_in_batches(batch_size: 1000) do |batch|
report.add_rows(batch)
end
# in_batches — yields ActiveRecord::Relation (Rails 5+)
User.in_batches(of: 200) do |relation|
relation.update_all(notified: true)
end
Default batch_size for find_each / find_in_batches is 1000.
Do not add .order to a find_each query — it overrides the internal ORDER BY id that batching relies on. Rails 7+ raises if you try.
Bulk Operations (no callbacks)
These are significantly faster for large updates/deletes, but skip ActiveRecord callbacks and validations.
# update_all — single UPDATE SQL
Post.where(published: false).update_all(archived: true, archived_at: Time.current)
# delete_all — single DELETE SQL (skips callbacks and dependent: :destroy)
Session.where("expires_at < ?", 30.days.ago).delete_all
# destroy_all — loads records and calls destroy on each (callbacks fire, slow)
User.where(banned: true).destroy_all
Use destroy_all only when callbacks and dependent: associations must fire. Prefer delete_all for large purges where callbacks are not needed.
Bulk insert with insert_all / upsert_all (Rails 6+)
# Insert many rows in a single statement — skips callbacks, validations, and timestamps
Post.insert_all(
[
{ title: "Post 1", user_id: 1, created_at: Time.current, updated_at: Time.current },
{ title: "Post 2", user_id: 1, created_at: Time.current, updated_at: Time.current },
]
)
# Upsert — INSERT ON CONFLICT DO UPDATE
User.upsert_all(
[{ email: "[email protected]", name: "Alice" }],
unique_by: :email,
update_only: [:name]
)
Associations: Loading vs Querying
# association_ids — returns cached IDs without loading full records
post.comment_ids
# .size — uses counter_cache if present, or COUNT; does not load association
post.comments.size
# .count — always fires COUNT(*) regardless of cache
post.comments.count
# .length — loads all records if not already loaded, then counts in Ruby
post.comments.length # avoid
Raw Values vs AR Objects
| Scenario | Use |
|---|---|
| Need single column values | pluck(:col) |
| Need multiple column values | pluck(:col1, :col2) |
| Need a key-value map | each_with_object on pluck result |
| Need IDs only | .ids |
| Need full AR object | find / where |
# Map user IDs to emails without loading AR objects
User.where(active: true).pluck(:id, :email).to_h
# => { 1 => "[email protected]", 2 => "[email protected]" }
SQL Injection Prevention
# VULNERABLE — user input interpolated directly into SQL
User.where("email = '#{params[:email]}'")
# SAFE — parameterized placeholders
User.where("email = ?", params[:email])
User.where(email: params[:email]) # hash syntax (preferred for equality)
User.where("created_at > ?", params[:since].to_time)
Query Analysis
# See the SQL without executing
User.where(active: true).order(:email).to_sql
# => "SELECT \"users\".* FROM \"users\" WHERE \"users\".\"active\" = TRUE ORDER BY \"users\".\"email\" ASC"
# Run EXPLAIN
User.where(email: "[email protected]").explain
# Prints EXPLAIN output from Postgres
# Run EXPLAIN ANALYZE (executes the query)
ActiveRecord::Base.connection.execute("EXPLAIN (ANALYZE, BUFFERS) SELECT ...")
Key things to look for in EXPLAIN ANALYZE:
Seq Scanon large tables — likely a missing index- Row estimate mismatch (
rows=Xvsactual rows=Y) — stale stats; runANALYZE tablename Nested Loopwith large row estimates — missing index on the inner relation- High
Buffers: shared read— cold cache or large scans
Scopes and Chainability
class Post < ApplicationRecord
scope :published, -> { where(published: true) }
scope :recent, -> { order(created_at: :desc) }
scope :by_author, ->(user) { where(author: user) }
end
# Scopes chain
Post.published.recent.limit(10)
Post.by_author(current_user).published
Scopes that might return no records should be avoided — prefer class methods with explicit guards if nil is possible.
Anti-Patterns
| Anti-pattern | Problem | Fix |
|---|---|---|
.all.each on large tables | Loads all records into memory; OOM | Use find_each |
| Association methods in loops without eager loading | N+1 queries | includes the association before the loop |
.count > 0 or .any? for presence | Full COUNT query | exists? — fires SELECT 1 LIMIT 1 |
.length on unloaded relation | Loads entire dataset | .size (uses cache/COUNT) or .count |
destroy_all on large tables | Loads every record, fires callbacks one-by-one | delete_all if callbacks aren't needed |
update_all without a scope | Updates every row in the table | Always scope before update_all |
String interpolation in where | SQL injection | Parameterized queries or hash syntax |
includes when filtering on association | Unexpected JOIN and row duplication | Use eager_load explicitly when adding where on the association |
select * on wide tables | Fetches unused columns; wastes memory and bandwidth | select only needed columns or use pluck |
Post.all.map(&:id) | Loads full AR objects to get IDs | Post.ids or Post.pluck(:id) |
What ships with it
Read from the repository
Just SKILL.md. No reference files, no scripts.