Skip to main content

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

A
Raza Hussain
· 8 min read · 209
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

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 like find_by but 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 ApplicationController that turns ActiveRecord::RecordNotFound into a :not_found JSON 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 on nil.
  • 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_by or a soft‑delete check. ✅ Pros: fewer retries; ❌ Cons: you must handle nil paths.
  • 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 find vs find_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

Be the first to vote!

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.

209

Leave a Response

Responses (0)

No responses yet

Be the first to share your thoughts

R

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.

💼 15 years experience 📝 34 posts