Railsmaxxing, or hidden Rails gems

railsmaxxing n. getting the most out of Rails by deleting code the framework already wrote for you.

ActiveRecord – querying

1. Pass a relation to where and get a subquery for free

# Two queries, huge ID array in Ruby memory:
User.where(id: Post.published.pluck(:user_id))

# One query, subquery in SQL:
User.where(id: Post.published.select(:user_id))
# => WHERE id IN (SELECT user_id FROM posts WHERE published = true)

The whole trick is select instead of pluck. pluck executes immediately and hands you an array; select stays lazy and composes.

2. where.not takes a relation too

User.where.not(id: Banned.select(:user_id))
# => WHERE id NOT IN (SELECT user_id FROM banned)

# Shorthand when you have records or a collection in hand:
Post.excluding(@spam_post)
Post.excluding(spam_posts)  # collections work too

Same mechanic, negated. Mind the usual NOT IN + NULL semantics.

3. pick is pluck.first in one query

User.where(admin: true).pick(:email)
# "[email protected]"

User.pick(:id, :email)
# [1, "[email protected]"]

4. sole and find_sole_by assert exactly one

User.find_sole_by(email: "[email protected]")
# Returns the user. Raises ActiveRecord::RecordNotFound for zero,
# ActiveRecord::SoleRecordExceeded for 2+.

# Works on arrays too:
[user].sole          # => user
[].sole              # => raises Enumerable::SoleItemExpectedError
[user1, user2].sole  # => raises Enumerable::SoleItemExpectedError

Great for code paths where “more than one” is a bug you want to hear about loudly.

5. in_order_of for arbitrary ordering

User.in_order_of(:status, %w[active trial canceled])
# Orders by the list. Filters to only these statuses by default;
# pass `filter: false` to keep the others (sorted after).

6. rewhere, regroup, reselect, unscope, only for surgical edits

# Replace a condition instead of ANDing a contradiction:
User.where(active: true).rewhere(active: false)

# Replace an existing GROUP BY:
scope.group(:author_id).regroup(:category_id)

# Strip clauses by name:
scope.unscope(:order).reselect(:id)

# Keep only specific clauses:
scope.only(:where)

Most scope-combination headaches come from trying to undo something a prior scope did. These are the verbs for that.

7. merge, or, and and for combining relations

# merge: compose another scope's conditions into this one
Post.published.merge(Post.authored_by(user))

# or: union of two relations (WHERE A OR WHERE B)
Post.published.or(Post.where(author: current_user))

# and: explicit intersection counterpart to `or`
Post.published.and(Post.authored_by(user))

merge is the workhorse for stitching scopes from different sources. or you’ll need almost as often. and is the rare case when you want the explicit intersection counterpart to or – usually merge is enough.

8. invert_where flips every condition

Post.published.featured.invert_where
# Negates EVERY WHERE on the chain – the example becomes
# "NOT (published AND featured)", not "featured but unpublished".
# Easy to surprise yourself with.

9. extending adds methods to a single relation

# You want report-style helpers on a relation, but they only make sense
# in one place. Adding them as scopes on User would clutter the model.

active_users = User.where(active: true).extending do
  def by_signup_month
    group("DATE_TRUNC('month', created_at)").count
  end

  def with_recent_activity
    where("last_seen_at > ?", 1.week.ago)
  end
end

active_users.by_signup_month
active_users.with_recent_activity.by_signup_month

10. ids beats pluck(:id)

User.where(active: true).ids

11. find_each and in_batches for big tables

User.find_each(batch_size: 500) { it.recompute_score! }

If you ever write User.all.each, you want this instead. (Ruby aside: it is Ruby 3.4’s shorthand for the single block parameter, like _1 but readable. Used a few times below.)

12. create_with sets defaults for a relation’s inserts

# Find a user by email; if creating, also set these:
User.create_with(role: "member", source: "signup_form")
    .find_or_create_by(email: params[:email])

The create_with defaults only apply on the create path. Existing users keep their values; new users get the defaults.

13. create_or_find_by dodges the SELECT-then-INSERT race

User.create_or_find_by(email: "[email protected]")
# INSERTs first, rescues RecordNotUnique, then SELECTs. Requires a
# unique DB constraint. Gotcha: a model-level `validates :email,
# uniqueness: true` defeats the pattern – the Ruby validation fails
# before the INSERT ever hits the DB, and you get back an invalid,
# unsaved record instead of the existing one.

14. update_counters for atomic bumps

Post.update_counters(post.id, views: 1, clicks: 3)
# Atomic. No callbacks. No race between select and update.

15. strict_loading raises on lazy association access

User.strict_loading.all
# Any lazy association access now raises. Find N+1s in CI.

16. load_async runs queries in parallel

def index
  @posts    = Post.recent.load_async
  @comments = Comment.recent.load_async
  # With an async query executor configured, both queries fire on
  # background threads and the first access awaits. Without one,
  # falls back to foreground execution – a silent no-op.
end

17. annotate tags SQL with context

User.all.annotate("HomeController#index")
# SELECT ... /* HomeController#index */

Your slow-query logs now tell you who to blame.

18. ActiveRecord::Associations::Preloader loads associations onto records you already have

# You pulled users from cache, or assembled them from multiple queries:
users = Rails.cache.fetch("hot_users") { User.hot.to_a }

# Now, deep in a view partial, you realize you need their posts.
# You can't re-query without losing the cache benefit.

ActiveRecord::Associations::Preloader
  .new(records: users, associations: [:posts, :profile])
  .call

users.first.posts  # preloaded, no N+1

ActiveRecord – model definition

19. Custom types with attribute

class MoneyType < ActiveRecord::Type::Value
  def cast(value)
    case value
    when Money   then value
    when Integer then Money.new(value)
    when String  then Money.new(value.gsub(/\D/, "").to_i)
    end
  end

  def serialize(value)
    value.is_a?(Money) ? value.cents : value
  end

  def deserialize(value)
    Money.new(value.to_i) if value
  end
end

class Product < ApplicationRecord
  attribute :price, MoneyType.new
end

product.price = "$19.99"
product.price      # => #<Money @cents=1999>
product.price.cents

20. store_accessor for named keys on a JSON column

class User < ApplicationRecord
  store_accessor :settings, :theme, :notifications
end

user.theme = "dark"  # writes into the settings JSON column

No type casting on its own – user.notifications = "false" stays the string "false". Pair with attribute :notifications, :boolean (see #19) when you want coercion.

21. normalizes for clean input (7.1+)

class User < ApplicationRecord
  normalizes :email, with: -> { it.strip.downcase }
end

Delete that before_validation you copy-pasted into every model. Applies on assignment and keyword-arg queries; pre-existing rows stay as-is until resaved.

22. has_secure_token for URL-safe tokens

class Invite < ApplicationRecord
  has_secure_token :code
end

Invite.create!.code  # => "Y3K8dMnTX9..."

23. generates_token_for for expiring signed tokens (7.1+)

class User < ApplicationRecord
  generates_token_for :password_reset, expires_in: 15.minutes do
    password_salt&.last(10)  # invalidates on password change
  end
end

token = user.generate_token_for(:password_reset)
User.find_by_token_for(:password_reset, token)

No password_reset_tokens table. No cleanup job.

user.signed_id(expires_in: 15.minutes, purpose: :unsubscribe)
User.find_signed(token, purpose: :unsubscribe)

Tamper-proof, optionally expiring. Not encrypted – the ID is readable to anyone with the token, only the signature prevents forgery. Zero DB columns.

25. ignored_columns hides DB columns during migrations

class User < ApplicationRecord
  self.ignored_columns = ["legacy_field"]
end

Deploy the code first, drop the column later. No 2AM.

ActiveRecord – persistence & callbacks

26. readonly! to prevent accidental mutation

# Audit log view - these records must never change.
@entries = AuditLog.where(actor: current_user).to_a
@entries.each(&:readonly!)

# Any downstream code path (shared serializer, view helper, after_find
# callback, a sneaky .update_column somewhere) that tries to mutate these
# will raise ActiveRecord::ReadOnlyRecord loudly instead of silently
# corrupting history.

Also useful for preview flows, historical snapshots, and anywhere you want mutation to be a crash, not a bug report three weeks later.

27. touch, no_touching, and belongs_to touch: true work together

class Post < ApplicationRecord
  has_many :comments

  after_touch :push_to_analytics

  private

  def push_to_analytics
    AnalyticsJob.perform_later(self)
  end
end

class Comment < ApplicationRecord
  belongs_to :post, touch: true
end

# Creating a comment cascades: Comment save -> Post#updated_at bumps ->
# Post#after_touch fires -> one analytics event per interaction. Cache
# keys based on the post's updated_at invalidate automatically too.

# But during a bulk import, you want to pause the cascade:
ActiveRecord::Base.no_touching do
  comment_attrs.each { Comment.create!(**it) }
end
# Then fire one aggregate event afterward.

# And sometimes you want to bump updated_at directly:
post.touch  # updates updated_at, fires after_touch and after_commit,
            # skips validations and save callbacks

28. after_all_transactions_commit kills the “enqueued too early” bug (7.2+)

after_create do
  ActiveRecord.after_all_transactions_commit do
    NotifyJob.perform_later(self)
    # Runs only after the OUTERMOST transaction commits. No more jobs
    # picking up records that don't exist yet because the test wrapped
    # everything in a transaction, or because you were inside a nested
    # save block.
  end
end

29. saved_change_to_*? for post-save dirty checks

after_save do
  # name_changed? is false here - it's about FUTURE saves.
  NotifyJob.perform_later(self) if saved_change_to_name?
end

A footgun that’s tripped up every Rails dev at least once.

30. previously_new_record? inside after_save

after_save do
  WelcomeMailer.deliver_later(self) if previously_new_record?
end

Deletes the @was_new = new_record?; super; ... dance.

ActiveModel

31. ActiveModel::Model turns any PORO into a form object

class ContactForm
  include ActiveModel::Model
  include ActiveModel::Attributes

  attribute :name,       :string
  attribute :age,        :integer
  attribute :subscribed, :boolean, default: false
  attribute :price,      MoneyType.new  # custom types work here too (see #19)

  validates :name, presence: true
  validates :age, numericality: { greater_than: 0 }
end

form = ContactForm.new(params[:contact])
form.valid?   # standard validations
form.age      # => Integer, properly cast from the string params
form.price    # => Money value object

# And it works with form_with, error messages, the whole view layer.

Every “service object that holds a few params and validates them” should look like this.

32. Validation contexts with on:

class User < ApplicationRecord
  validates :email, presence: true, uniqueness: true
  validates :terms_accepted, acceptance: true, on: :account_setup
  validates :phone_number,   presence: true,   on: :checkout
end

user.valid?                   # base validations only
user.valid?(:account_setup)   # base + terms_accepted
user.valid?(:checkout)        # base + phone_number

Contexts can be any symbol you invent, or Rails’ built-ins (:create, :update). Lets one model serve multiple flows without a jungle of if: conditions on every validator.

33. with_options groups shared options

# With validation contexts (synergy with #32):
class User < ApplicationRecord
  with_options on: :account_setup do
    validates :email,          uniqueness: true
    validates :terms_accepted, acceptance: true
    validates :password,       length: { minimum: 12 }
  end
end

# With associations:
class Post < ApplicationRecord
  with_options dependent: :destroy do
    has_many :comments
    has_many :likes
    has_many :reports
  end
end

# With callbacks:
class Order < ApplicationRecord
  with_options if: :paid? do
    after_commit :fulfill
    after_commit :send_receipt
    after_commit :notify_warehouse
  end
end

# Also works in config/routes.rb with scope options.

DRY without a meta-programming incident.

Routing

34. resolve in routes.rb teaches url_for new tricks

# config/routes.rb
#   get "/profile/:slug", to: "profiles#show", as: :profile
resolve("User") { |user, options| [:profile, options.merge(slug: user.slug)] }

# Anywhere:
link_to "View", @user  # routes to /profile/:slug, not /users/:id
redirect_to @user      # same

Great for STI types, custom URL schemes, or when the “natural” path helper doesn’t match the model name.

35. default_url_options sets defaults for every helper

# Common case: locale and tracking on every URL.
class ApplicationController < ActionController::Base
  def default_url_options
    { locale: I18n.locale, ref: params[:ref] }
  end
end

post_path(@post)
# => "/posts/123?locale=pl&ref=newsletter"

# Less common: scope it to one flow. Preview tokens stick to every link
# rendered inside this controller, no link_to acrobatics required.
class PreviewsController < ApplicationController
  def default_url_options
    super.merge(preview: params[:preview_token])
  end
end

Beats threading params through every link_to.

Controllers

36. ActionController::Metal for ultra-thin endpoints

# Bare-bones controller, minus ActionController::Base niceties:
class Ping < ActionController::Metal
  def show
    self.response_body = "ok"
  end
end
# routes.rb: get "/ping" => "ping#show"

# Opt back into just what you need - here, rendering without the rest:
class LandingPage < ActionController::Metal
  include AbstractController::Rendering
  include ActionView::Rendering
  include ActionView::Layouts

  append_view_path Rails.root.join("app/views")
  layout "marketing"

  def show
    render template: "pages/landing"
  end
end

Health checks, webhooks, cacheable marketing pages. Skip the auth, session, cookies, flash, and fifteen other controller features you don’t need on a homepage.

37. ActionController::Renderer renders outside a controller

ApplicationController.renderer.render(
  template: "reports/show",
  assigns:  { report: report }
)

Useful for jobs that email HTML, PDF generators, preview workers.

38. rate_limit is built in (7.2+)

class SessionsController < ApplicationController
  rate_limit to: 10, within: 3.minutes, only: :create
end

No gem. No Redis gymnastics in your base controller.

39. params.expect is stricter than require + permit (8.0+)

# Old:
params.require(:post).permit(:title, :body)

# New:
params.expect(post: [:title, :body])
# Raises on shape mismatches instead of silently passing weird input through.

ActiveSupport – core extensions

40. inquiry for expressive value queries

status = "active".inquiry
status.active?    # => true
status.archived?  # => false

Exactly how Rails.env.development? works. Now it’s yours.

41. Time#change and Date#change for surgical edits

Time.current.change(hour: 0, min: 0, sec: 0)
# Start of today, no arithmetic acrobatics.

Date.today.change(day: 1)
# First of this month.

42. Time.use_zone, I18n.with_locale, and other scoped overrides

class SendMonthlyReportJob < ApplicationJob
  def perform(customer_id)
    customer = Customer.find(customer_id)

    Time.use_zone(customer.time_zone) do
      I18n.with_locale(customer.locale) do
        ReportMailer.monthly_summary(
          customer,
          period_start: Time.zone.now.beginning_of_month
        ).deliver_now
      end
    end
  end
end

A background job runs in the server’s default zone and locale. A customer in Tokyo expects their “monthly” summary to end at midnight Tokyo time, not UTC, with numbers and copy in Japanese. Two nested blocks, zero threading through every formatter and translator. The same block-scoped-override pattern shows up in ActiveRecord::Base.connected_to(role: :reading) { ... } (read replicas), no_touching (from #27), and ActiveSupport::Notifications.subscribed(callback, pattern) { ... } (a temporary subscriber that unsubscribes on exit). Scoped context is one of Rails’ most underused patterns.

43. index_by and index_with for array → hash conversions

users = User.where(active: true).to_a

# Array -> hash keyed by a method:
users.index_by(&:email)
# { "[email protected]" => #<User 1>, "[email protected]" => #<User 2> }

# Array -> hash with computed values:
users.index_with { it.posts.count }
# { #<User 1> => 4, #<User 2> => 0 }

Every “I was about to write a reduce” starts here.

ActiveSupport – utilities

44. CurrentAttributes for per-request globals, done right

class Current < ActiveSupport::CurrentAttributes
  attribute :user, :tenant
end

# in a before_action:
Current.user = @user

# anywhere in the request:
Current.user

Auto-resets between requests. The answer to every “how do I access current_user in a model” question. (Just kidding, please don’t do it in models.)

45. ActiveSupport::Notifications for first-class pub/sub

ActiveSupport::Notifications.subscribe("sql.active_record") do |*, payload|
  Rails.logger.info(payload[:sql])
end

# Emit your own:
ActiveSupport::Notifications.instrument("checkout.complete", order: order) do
  charge!
end

46. ActiveSupport::Rescuable for rescue_from outside controllers

class MyService
  include ActiveSupport::Rescuable

  rescue_from(SomeError) { |e| Rails.error.report(e); :handled }

  def call
    risky_work
  rescue => e
    raise unless rescue_with_handler(e)
  end
end

47. Rails.error unifies error reporting (7.1+)

Rails.error.handle { risky_operation }   # swallow + report
Rails.error.record { tolerant_work }     # report + re-raise
Rails.error.report(e, context: { user_id: user.id })

Sentry, Honeybadger, whatever – they plug in as subscribers. One interface for your whole app.

48. MessageVerifier and MessageEncryptor for signed and encrypted payloads

verifier = Rails.application.message_verifier(:password_reset)
token = verifier.generate(user.id, expires_in: 15.minutes)
user_id = verifier.verify(token)  # raises if tampered or expired

The machinery under signed cookies and signed IDs, exposed directly.

49. SecurityUtils.secure_compare for timing-safe comparisons

ActiveSupport::SecurityUtils.secure_compare(given_token, stored_token)

One line that closes a timing-attack hole in every hand-rolled token check.

View helpers

50. dom_id and dom_class for stable DOM references

<%= content_tag :div, id: dom_id(@post) do %>
  <%# => <div id="post_123"> %>
<% end %>

A natural fit for Turbo Streams.

51. View helpers you keep reimplementing from scratch

# Numbers:
number_to_human_size(1_234_567_890)            # => "1.15 GB"
number_to_human(1_234_567)                     # => "1.23 Million"
number_to_currency(1234.5)                     # => "$1,234.50"
number_with_delimiter(12_345_678)              # => "12,345,678"
number_to_percentage(66.5, precision: 1)       # => "66.5%"
number_to_phone(5551234567, area_code: true)   # => "(555) 123-4567"

# Time:
distance_of_time_in_words(Time.now, 3.hours.from_now)  # => "about 3 hours"
time_ago_in_words(post.created_at)                     # => "6 days"

# Strings:
pluralize(3, "comment")                        # => "3 comments"
pluralize(1, "person", plural: "people")       # => "1 person"
truncate("a very long string here", length: 12) # => "a very lo..."
excerpt("the quick brown fox", "brown", radius: 5) # => "...uick brown fox"
simple_format("line one\n\nline two")          # => "<p>line one</p>\n\n<p>line two</p>"

Every one of these gets hand-rolled in some codebase, right now, as you read this. Stop that.

52. fragment_exist? to skip work that only feeds a cached fragment

class ProductsController < ApplicationController
  def show
    @product = Product.find(params[:id])

    # Skip the expensive query when the fragment is already cached:
    unless fragment_exist?([@product, :reviews])
      @review_stats = ExpensiveReviewAnalysis.run(@product)
    end
  end
end
<%# In the view – skip_digest: true is crucial. %>
<%# Default `cache` appends the template digest to the key; controller %>
<%# `fragment_exist?` doesn't, so without skip_digest they never match. %>
<% cache [@product, :reviews], skip_digest: true do %>
  <%= render "review_summary", product: @product, stats: @review_stats %>
<% end %>

Without the check, ExpensiveReviewAnalysis.run fires on every request – even ones served entirely from cache. The whole point of the fragment was to skip that work.

Configuration

53. config_for for per-environment YAML

# config/search.yml
default: &default
  index_prefix: <%= Rails.env %>
  timeout: 5

development:
  <<: *default
  host: http://localhost:9200

production:
  <<: *default
  host: <%= ENV["ELASTICSEARCH_URL"] %>
  timeout: 15
config = Rails.application.config_for(:search)
Elasticsearch::Client.new(host: config.host, request_timeout: config.timeout)

Testing

54. travel, travel_to, and freeze_time replace Timecop

class OrderTest < ActiveSupport::TestCase
  include ActiveSupport::Testing::TimeHelpers

  test "expires after a week" do
    order = Order.create!
    travel 8.days
    assert order.reload.expired?
  end
end

Delete the Timecop dependency this afternoon.

Console & CLI

55. rails console --sandbox rolls back on exit

$ rails console --sandbox
Loading development environment in sandbox
Any modifications you make will be rolled back on exit

56. app and helper in the console

# Fire real requests through the full stack:
app.get "/users/1"
app.response.status   # => 200
app.response.body     # => "<!DOCTYPE html>..."

# Use route helpers without a controller:
app.post_path(Post.first)   # => "/posts/42-my-title"
app.root_url                # => "http://www.example.com/"

# Use view helpers without a view:
helper.number_to_human_size(1_234_567_890)   # => "1.15 GB"
helper.time_ago_in_words(1.hour.ago)         # => "about 1 hour"
helper.pluralize(3, "comment")               # => "3 comments"

# Pick up code changes without restarting:
reload!

Useful for debugging. A whole class of “let me write a quick script to reproduce” problems disappears.

57. rails routes -g and -c

$ rails routes -g users             # grep by keyword
$ rails routes -c PostsController   # filter by controller

Instead of scrolling four thousand lines.

58. rails runner for one-off scripts

$ bin/rails runner 'puts User.where(active: true).count'
$ bin/rails runner script/backfill_tags.rb

Full app booted. No require_relative gymnastics.


Turdquoises, or the anti-gems

A few Rails features have the same shape as the ones above – clean name, short docs, fits in one line. They bite when you actually use them.

59. Model.suppress

Notification.suppress do
  comment.save!  # comment creation internally creates a Notification
end

Reads like “don’t fire notifications inside this block.” Does something else: every Notification.save inside the block silently no-ops. No error, no log, no rescued exception to grep for. Six months later someone asks why one notification type stopped firing and you trace it here.

60. delegate_missing_to :wrapped

class Decorator
  def initialize(wrapped) = @wrapped = wrapped
  delegate_missing_to :@wrapped
end

The most elegant decorator pattern in Ruby, until the wrapped object has its own method_missing – Hashes with indifferent access, ActiveRecord associations, OpenStruct, anything that defines method_missing permissively. Then decorator.naem silently succeeds through the wrapped side, a typo becomes data corruption, and your decorator’s surface isn’t introspectable (methods doesn’t list delegated ones).

61. autosave: true on a has_many

class Order < ApplicationRecord
  has_many :line_items, autosave: true
end

Reads like “save the whole graph in one call.” Does that, plus persists any child you mutated in memory but weren’t ready to save. Child errors flatten onto the parent (“Line items sku can’t be blank”) – with a dozen line items, good luck figuring out which one. Add accepts_nested_attributes_for and transaction boundaries get weird in ways that take a debugger to untangle.


58 gems, 3 traps. Go delete some code. If anyone asks, tell them you railsmaxxed.