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.
24. 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, 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.