Railsmaxxing, or hidden Rails gems
railsmaxxing n. using more Rails, and writing less of everything else.
ActiveRecord – querying
1. 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).
2. 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, while select stays lazy and composes into the outer query as a subquery.
3. 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
4. annotate tags SQL with context
User.all.annotate("HomeController#index")
# SELECT ... /* HomeController#index */
Your slow-query logs now tell you who to blame.
5. strict_loading raises on lazy association access
User.strict_loading.all
# Any lazy association access now raises. Find N+1s in CI.
6. 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 subquery trick as in #2, negated. Mind the usual NOT IN + NULL semantics.
7. pick is pluck.first in one query
User.where(admin: true).pick(:email)
# "admin@example.com"
User.pick(:id, :email)
# [1, "admin@example.com"]
8. sole and find_sole_by assert exactly one
User.find_sole_by(email: "x@y.z")
# 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.
9. 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.
10. 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.
11. 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.
12. 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
13. ids beats pluck(:id)
User.where(active: true).ids
14. 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.)
15. 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, and new users get the defaults.
16. create_or_find_by dodges the SELECT-then-INSERT race
User.create_or_find_by(email: "x@y.z")
# 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.
17. update_counters for atomic bumps
Post.update_counters(post.id, views: 1, clicks: 3)
# Single atomic UPDATE, with no callbacks and no select-then-update race.
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 – persistence & callbacks
19. readonly! to prevent accidental writes
# Audit log view – these records must never be saved back.
@entries = AuditLog.where(actor: current_user).to_a
@entries.each(&:readonly!)
# A shared serializer, view helper, or after_find callback can still mutate
# the Ruby object. If it tries to persist, Rails raises
# ActiveRecord::ReadOnlyRecord instead of rewriting history.
Also useful for preview flows and historical snapshots.
20. 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
21. 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
22. 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.
23. 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.
ActiveRecord – model definition
24. 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
25. 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". attribute :notifications, :boolean defines a separate model attribute; it does not type-cast the JSON key. Add explicit reader/writer methods if a stored key needs coercion.
26. normalizes for clean input (7.1+)
class User < ApplicationRecord
normalizes :email, with: -> { it.strip.downcase }
end
Applies on assignment and on keyword-arg queries, and pre-existing rows stay as they were until someone resaves them.
27. has_secure_token for URL-safe tokens
class Invite < ApplicationRecord
has_secure_token :code
end
Invite.create!.code # => "Y3K8dMnTX9..."
28. 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)
29. signed_id and find_signed for one-off link tokens
user.signed_id(expires_in: 15.minutes, purpose: :unsubscribe)
User.find_signed(token, purpose: :unsubscribe)
Tamper-proof and optionally expiring, but not encrypted – the ID is readable to anyone who has the token, and only the signature prevents forgery.
30. ignored_columns hides DB columns during migrations
class User < ApplicationRecord
self.ignored_columns = ["legacy_field"]
end
Lets you drop a column in two deploys: ship the code that ignores it first, then drop the column once that code is live everywhere.
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 #24)
validates :name, presence: true
validates :age, numericality: { greater_than: 0 }
end
form = ContactForm.new(params.expect(contact: [
:name,
:age,
:subscribed,
:price
]))
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.
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 (pairs 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 PingController < 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
Good fits are things like health checks, webhooks, and cacheable marketing pages – places where you want to skip auth, session, cookies, and flash entirely.
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
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 malformed params 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.
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 ReportsController < ApplicationController
def show
Time.use_zone(current_customer.time_zone) do
I18n.with_locale(current_customer.locale) do
@report = MonthlyReport.new(
current_customer,
period_start: Time.zone.today.beginning_of_month
)
render :show
end
end
end
end
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)
# { "alice@example.com" => #<User 1>, "bob@example.com" => #<User 2> }
# Array -> hash with computed values:
users.index_with { it.posts.count }
# { #<User 1> => 4, #<User 2> => 0 }
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. Don’t reach for it in models – request context belongs in the controller.
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) }
def call
risky_work
rescue => e
handled = rescue_with_handler(e)
raise unless handled
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>"
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
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 – or for driving the app from the REPL when you’d otherwise write a throwaway script.
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.
Turdquoises, or the anti-gems
A few Rails features look the same as the ones above – clean name, short docs. They bite when you actually use them.
59. Model.suppress
Notification.suppress do
comment.save! # comment creation internally creates a Notification
end
suppress sounds like it mutes delivery. It goes lower: every Notification#save or save! inside the block returns success without writing a row. The surrounding callback chain keeps running, no exception points at the suppressed model, and code that later expects a notification now has a missing record with no obvious culprit. It works inside a narrow test harness where you want writes suppressed on purpose, but as everyday application control flow it’s a trap – the missing record tends to surface far away from the suppress call.
60. delegate_missing_to :wrapped
class Decorator
def initialize(wrapped) = @wrapped = wrapped
delegate_missing_to :@wrapped
end
On the page it looks like a tidy decorator. The catch is that the wrapper’s public API becomes whatever @wrapped claims to answer today: dynamic methods, gem-added methods, and any broad method_missing. A typo like decorator.naem gets forwarded through method_missing to the wrapped object instead of failing at the boundary where the typo happened. Explicit delegate :name, :email, to: :@wrapped is dull in the best possible way.
61. autosave: true on a has_many
class Order < ApplicationRecord
has_many :line_items, autosave: true
end
Fine when the association is two nested form rows. With a real has_many, order.save! can also save every loaded child that became dirty on the way there. That makes the parent save depend on object graph state: a callback, importer, or form object can change what gets written. Validation errors then bubble up as parent errors like Line items sku can't be blank, which doesn’t tell you which of twelve line items to fix.
That’s 58 gems and 3 traps. If anyone asks, tell them you railsmaxxed.