agentsclimarketplace

Rails activerecord queries

Skill Tyr0/agent-skills/plugins/rails-expert/skills/rails-activerecord-queries

A collection of skills, plugins, and agents for AI workflows.

Install
npx -y skills add Tyr0/agent-skills --skill rails-activerecord-queries

Assembled 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 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.

SKILL.md

8.9 KB, 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

MethodSQL StrategyUse when
includesRails decides (preload or LEFT JOIN)Default choice; let Rails optimize
preloadAlways separate queriesAvoid Cartesian product with has_many + conditions
eager_loadAlways LEFT OUTER JOINNeed 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

ScenarioUse
Need single column valuespluck(:col)
Need multiple column valuespluck(:col1, :col2)
Need a key-value mapeach_with_object on pluck result
Need IDs only.ids
Need full AR objectfind / 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 Scan on large tables — likely a missing index
  • Row estimate mismatch (rows=X vs actual rows=Y) — stale stats; run ANALYZE tablename
  • Nested Loop with 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-patternProblemFix
.all.each on large tablesLoads all records into memory; OOMUse find_each
Association methods in loops without eager loadingN+1 queriesincludes the association before the loop
.count > 0 or .any? for presenceFull COUNT queryexists? — fires SELECT 1 LIMIT 1
.length on unloaded relationLoads entire dataset.size (uses cache/COUNT) or .count
destroy_all on large tablesLoads every record, fires callbacks one-by-onedelete_all if callbacks aren't needed
update_all without a scopeUpdates every row in the tableAlways scope before update_all
String interpolation in whereSQL injectionParameterized queries or hash syntax
includes when filtering on associationUnexpected JOIN and row duplicationUse eager_load explicitly when adding where on the association
select * on wide tablesFetches unused columns; wastes memory and bandwidthselect only needed columns or use pluck
Post.all.map(&:id)Loads full AR objects to get IDsPost.ids or Post.pluck(:id)

Keep looking

Skills are one crate of 328,083. Ordering is by how many stacks a row turns up in, so the top of any crate is what has actually been picked rather than what has the most stars.