- Home
- Blog
- Ruby & Rails Core
- Used find() on nil. App Crashed in Production. Should've Used find_by(). One Raises, One Returns nil.
Used find() on nil. App Crashed in Production. Should've Used find_by(). One Raises, One Returns nil.
The query method confusion that kills production deployments—and knowing which finder raises exceptions vs returns nil
Used find() on nil. App Crashed in Production. Should’ve Used find_by(). One Raises, One Returns nil.
The query method confusion that kills production deployments—and knowing which finder raises exceptions vs returns nil
Your pager goes off during a deploy. Error rate jumps, Sidekiq retries explode, and the API starts returning 500s. Root cause? A controller called User.find(params[:id]) for a soft-deleted user. ActiveRecord::RecordNotFound bubbled up and took the whole request down. Sound familiar? The trap is simple: find raises, find_by returns nil. The fix isn’t “always use one.” It’s choosing intentionally based on the contract of each endpoint and handling the missing-record case on purpose.
Below is what actually works in production, the trade‑offs, and the patterns I ship on Rails 7.1 / Ruby 3.2.
Watch out:
find_by!exists too. It behaves likefind_bybut raises. Use it when you want flexible where clauses and the “must exist” contract.
The Contract: Does This Record Have to Exist?
Start with the question I ask in code review: Is the record’s existence a precondition or a branch? If it’s a precondition (e.g., /accounts/:id must show a real account), prefer find or find_by! so the 404 path is automatic. If it’s a branch (e.g., optional association), prefer find_by and handle nil explicitly.
Why it matters: in one app (≈ 50K DAU, 200K requests/day), a single find in a background job raised for 1.4% of runs after a data migration. That spiked retries from <0.5% to 7.8% for two hours and delayed emails by 12–15 minutes P95. The code worked in staging; reality hit production data.
# Rails 7.1, Ruby 3.2
class Api::AccountsController < ApplicationController
# We *want* a 404 when the account is missing. This keeps the controller honest
# and avoids leaking which IDs do/don't exist.
def show
account = current_user.accounts.find(params[:id])
render json: { id: account.id, name: account.name }
end
end
# Optional association: profile might not exist yet. Using find would 500.
class UsersController < ApplicationController
def show
@user = User.find(params[:id])
# Use find_by because the absence is a *valid branch* we render differently.
@profile = Profile.find_by(user_id: @user.id)
end
end
Pro tip: If you need a 404 JSON instead of HTML, set a global rescue in
ApplicationControllerthat turnsActiveRecord::RecordNotFoundinto a:not_foundJSON payload.
Choosing Between find, find_by, and find_by!
Here’s the rule of thumb I enforce in reviews:
-
Use
find(id)when the URL or job requires the record and you’re comfortable with a 404 on miss. It’s the fastest path to the right failure mode. -
Use
find_by(…conditions…)when missing is expected and you’ll branch onnil. -
Use
find_by!(…conditions…)when you want conditions and the 404 behavior.
# Controller: must exist
before_action :set_account
def set_account
# "Must exist" contract: 404s automatically via RecordNotFound
@account = current_user.accounts.find(params[:id])
end
# Service object: optional id filter from params
module Reports
class BuildRevenue
def initialize(account_id: nil)
@account = Account.find_by(id: account_id) # optional; nil means all accounts
end
def call
scope = Invoice.paid
scope = scope.where(account: @account) if @account # Branch on nil intentionally
scope.sum(:amount_cents)
end
end
end
Performance note: The execution plan difference between find and find_by(id: …) is negligible—they both use the primary key index. The real difference is exception vs nil, which cascades into control flow, error logs, and retry behavior.
Error Handling Patterns That Don’t Wake You at 3am
The mistake I see is swallowing exceptions or letting them crash unrelated work. Centralize the behavior.
# app/controllers/application_controller.rb
class ApplicationController < ActionController::Base
rescue_from ActiveRecord::RecordNotFound do |e|
# Prefer 404 to 500; don’t reveal internal messages
render json: { error: "Not found" }, status: :not_found
end
end
# Background job (Rails 7.1’s Solid Queue or Sidekiq—both fine)
class SyncBillingJob < ApplicationJob
queue_as :default # Solid Queue / ActiveJob works; Sidekiq performs too
def perform(account_id)
# Use find_by because the account might be deleted between enqueue & perform
account = Account.find_by(id: account_id)
return unless account # Drop gracefully instead of retrying forever
# … do work …
end
end
Why? Jobs run minutes later; deletes happen. Using find here caused 1,300 retries/hour in my logs after a cleanup task ran. Switching to find_by dropped it to <10 retries/hour and cleared the queue in 7 minutes (was >45 minutes). Tools involved: Sidekiq for high‑volume queues, Solid Queue in lighter installs, StandardRB to enforce consistent controller rescue, and Brakeman to keep security holes out during refactors.
Real talk: Exceptions aren’t failures—surprises are. Design your finders so production behavior is boring.
Guard Rails in Scopes and Associations
Another foot‑gun: hiding find calls inside scopes or callbacks. Keep the contract obvious.
class Subscription < ApplicationRecord
belongs_to :account
scope :for_domain, ->(domain) {
# Use find_by! to fail fast *only* in places where domain must exist.
# If a domain can be created lazily, pass the object or id explicitly instead.
account = Account.find_by!(domain: domain)
where(account_id: account.id)
}
end
# Safer alternative: accept Account directly to avoid hidden finder behavior
class Subscription < ApplicationRecord
belongs_to :account
scope :for_account, ->(account) {
# Using the id avoids redundant lookups and surprises
where(account_id: account.id)
}
end
In production, the first pattern caused sporadic 404s when a sales import referenced domains that hadn’t synced yet. Rewriting the scope and updating the call sites cut failed requests from ~3% to <0.2% on the admin dashboard that hit this query ~9K times/day.
Tests That Lock In the Contract
Write tests that assert the behavior you expect when records are missing.
# RSpec examples
RSpec.describe Api::AccountsController do
describe 'GET /api/accounts/:id' do
it '404s when the account is missing' do
get "/api/accounts/999999"
expect(response).to have_http_status(:not_found)
end
end
end
RSpec.describe SyncBillingJob do
it 'exits quietly when account is missing' do
expect { described_class.perform_now(-1) }.not_to raise_error
end
end
These tests catch the accidental find that turns a gentle branch into a pager duty drill.
When NOT to Use find (and When You Should)
Don’t use find:
- In background jobs that accept IDs from outside processes. Records disappear; prefer
find_byor a soft‑delete check. ✅ Pros: fewer retries; ❌ Cons: you must handlenilpaths. - For optional associations (profile, settings). ✅ Pros: simpler views; ❌ Cons: you must deliberately render the empty state.
- In queries tied to user‑controlled input where a 404 could be abused to map your data surface. ✅ Pros: less information leakage; ❌ Cons: more code paths to test.
Do use find (or find_by!):
- On show/edit/update/destroy actions for nested resources the current user must own. ✅ Pros: automatic 404 & CSRF‑safe path; ❌ Cons: raised exception if you don’t globally rescue.
- In admin tools where missing implies data corruption you want to know about. ✅ Pros: fast failure; ❌ Cons: noisy logs if misused.
- In transactions where the absence invalidates the entire operation. ✅ Pros: clearer rollback semantics; ❌ Cons: requires good exception boundaries.
Performance note: The cost is in exception handling and retries, not the SQL. Measure queue depth, retry rate, and controller 404 ratios rather than micro‑benchmarking
findvsfind_by.
Before/After: The Production Crash I Shipped
Before (crashy):
# Nightly “reconcile subscriptions” job
class ReconcileSubscriptionsJob < ApplicationJob
def perform(subscription_id)
# This raised 1.4% of the time after deletes and blocked the queue via retries
subscription = Subscription.find(subscription_id)
Billing::Reconcile.call(subscription)
end
end
After (boring):
class ReconcileSubscriptionsJob < ApplicationJob
def perform(subscription_id)
# Find softly; skip work if gone to keep the queue moving
subscription = Subscription.find_by(id: subscription_id)
return unless subscription
Billing::Reconcile.call(subscription)
end
end
Results from logs over 30 days: job retry rate down from 6.2% to 0.4%, average job latency dropped from 2m 10s to 24s, and the queue stayed under 500 jobs even at peak (was >9K). That’s the difference the right finder makes.
Quick Reference (Copy/Paste)
# Must exist (404 on miss)
user = current_team.users.find(params[:id])
# Optional association (nil on miss)
profile = user.profile || Profile.find_by(user_id: user.id)
# Flexible conditions + raise on miss
invoice = Invoice.find_by!(account_id: account.id, number: params[:number])
# Background job: IDs can be stale
return unless (account = Account.find_by(id: account_id))
Pro tip: Pair this with Pagy for predictable pagination and Sidekiq (or Solid Queue) for jobs. Add StandardRB to keep rescue patterns consistent and Brakeman to scan for unsafe string SQL while you refactor finds into find_bys.
Final Thoughts
Reach for find when the record’s existence is a requirement and a 404 is correct. Reach for find_by when absence is an expected branch you’ll handle. The trade‑off is clarity vs control: exceptions simplify controllers but can blow up jobs; nil keeps queues calm but forces explicit code paths. Start by stating the contract in each endpoint and job, then choose the finder that enforces it.
Was this article helpful?
Your feedback helps us improve our content
How We Verify Conversions
Every conversion shown on this site follows a strict verification process to ensure correctness:
- Compare results on same dataset — We run both SQL and ActiveRecord against identical test data and verify results match
-
Check generated SQL with
to_sql— We inspect the actual SQL Rails generates to catch semantic differences (INNER vs LEFT JOIN, WHERE vs ON, etc.) - Add regression tests for tricky cases — Edge cases like NOT EXISTS, anti-joins, and predicate placement are tested with multiple scenarios
- Tested on Rails 8.1.1 — All conversions verified on current Rails version to ensure compatibility
Last updated: February 22, 2026
Try These Queries in Our Converter
See the SQL examples from this article converted to ActiveRecord—and compare the SQL Rails actually generates.
Deep Dive into ActiveRecord
Raza Hussain
Full-stack developer specializing in Ruby on Rails, React, and modern JavaScript. 15+ years upgrading and maintaining production Rails apps. Led Rails 4/5 → 7 upgrades with 40% performance gains, migrated apps from Heroku to Render cutting costs by 35%, and built systems for StatusGator, CryptoZombies, and others. Available for Rails upgrades, performance work, and cloud migrations.
More on SQL to ActiveRecord
Junior Dev Asked "Why Not Just Use SQL?" Gave Textbook Answer. They Weren't Convinced. Then Production Happened.
Not ideology—operations. See how ActiveRecord cut P95 from 1.2s→220ms, dropped queries 501→7, and avoided schema-change bugs. When to use SQL safely, too.
SQL Certification on Resume. Rails Interview Failed. Knew Databases. Didn't Know ActiveRecord.
SQL cert on your resume but Rails interview still flopped? Learn the ActiveRecord skills interviews test—associations, eager loading, batching, and when to use raw SQL.
Read "Agile Web Development with Rails." Still Couldn't Write Queries. Needed Examples, Not Theory.
Books teach concepts. You need examples. See SQL vs ActiveRecord side-by-side, when to use scopes/Arel/SQL, and how to ship maintainable queries fast.
Leave a Response
Responses (0)
No responses yet
Be the first to share your thoughts