Skip to content
ExponaBLOG
RUBY ON RAILS · DAY 081 / 100

Common Ruby mistakes: a Ruby on Rails guide for developers

Language gotchas.

Expona Admin8 min read

Ruby's permissive syntax and flexible object model are a double-edged sword. They enable rapid development and elegant code, but they also create subtle traps that catch even experienced developers. Understanding these common Ruby mistakes—particularly in a Rails context—can save you hours of debugging and prevent production incidents.

Understanding nil errors from missing data

One of the most frequent runtime errors in Rails applications is the NoMethodError: undefined method for nil:NilClass. This happens when you call a method on nil, typically because your code assumes data exists when it doesn't.

In Rails, this problem compounds because ActiveRecord queries can return nil silently. Consider a typical scenario:

ruby
user = User.find_by(email: 'test@example.com')
puts user.account.subscription.plan_name

If the user doesn't exist, find_by returns nil. The next line crashes with an unhelpful error message. The developer might think the problem is in the account relationship, when it's actually one level higher.

Rails gives us several defensive patterns. The safest approach depends on your use case:

ruby
# Pattern 1: Safe navigation operator (Ruby 2.3+)
plan_name = user&.account&.subscription&.plan_name

# Pattern 2: Explicit nil checks
plan_name = user.account.subscription.plan_name if user&.account&.subscription

# Pattern 3: ActiveRecord presence checks
plan_name = user.account.subscription.plan_name if user.present? && user.account.present?

# Pattern 4: Try method (Rails-specific)
plan_name = user.try(:account).try(:subscription).try(:plan_name)

The safe navigation operator is modern and readable, but try is still common in established codebases. The choice matters less than consistency within your team.

A deeper issue emerges when you mix optional and required relationships. If User always has an Account, but Account sometimes lacks a Subscription, document this in your model comments and controller validations. Use ActiveRecord scopes to make these assumptions explicit:

ruby
class User < ApplicationRecord
  has_one :account
  
  # Returns only users who have a subscription
  scope :with_subscription, -> { 
    joins(account: :subscription).distinct 
  }
end

# In your controller
@users = User.with_subscription
@users.each do |user|
  # Safe: we know subscription exists
  puts user.account.subscription.plan_name
end

Mutating frozen strings

Ruby 3.0 introduced frozen string literals by default in many Rails projects. Attempting to mutate a frozen string raises a FrozenError. This change caught many teams off guard because frozen strings behave identically to regular strings until you try to modify them.

Here's where mutation happens and how it fails:

ruby
# This raises FrozenError
message = "Hello"
message << " World"  # FrozenError: can't modify frozen String

# These also mutate and will fail:
message.upcase!  # FrozenError
message[0] = 'h'  # FrozenError
message.reverse!  # FrozenError

The issue often surfaces when you're building strings in loops or working with string defaults in models:

ruby
class Report
  def initialize
    @output = "Report Data:\n"  # Frozen string literal
  end
  
  def add_line(content)
    @output << content  # Breaks if @output is frozen
  end
end

Solutions depend on your intent. If you need a mutable string, create one explicitly:

ruby
class Report
  def initialize
    @output = String.new("Report Data:\n")  # Mutable copy
  end
  
  def add_line(content)
    @output << content  # Works fine
  end
end

# Or use dup to create a mutable copy
message = "Hello"
mutable = message.dup
mutable << " World"  # Works

# String concatenation returns a new string (doesn't mutate original)
message = "Hello"
message = message + " World"  # This works

The frozen string default reflects modern Ruby's preference for immutability. Rather than fighting it, embrace it. Use string concatenation with + instead of mutation operators, or explicitly mark strings as mutable when your logic requires it. This catches more bugs at development time rather than in production.

Confusing == and equal?

Ruby offers multiple ways to compare objects, and mixing them up creates logic bugs that can bypass validations, authentication checks, or billing logic.

The == operator compares value, while equal? compares object identity:

ruby
a = "hello"
b = "hello"

a == b        # true (same content)
a.equal?(b)   # false (different objects)

# For numbers, both usually agree
x = 5
y = 5
x == y        # true
x.equal?(y)   # true (small integers are cached)

# But with larger numbers or objects, they diverge
arr1 = [1, 2, 3]
arr2 = [1, 2, 3]
arr1 == arr2        # true (same elements)
arr1.equal?(arr2)   # false (different arrays)

In Rails applications, this distinction matters most when comparing ActiveRecord objects:

ruby
user1 = User.find(1)
user2 = User.find(1)

user1 == user2        # true (same database record)
user1.equal?(user2)   # false (different Ruby objects)

# This is why memoization can be risky:
def current_user
  @current_user = User.find(session[:user_id])  # Fetches fresh copy each time
end

# Safer: trust Rails's identity map or use object_id
if user1.object_id == user2.object_id  # They're the exact same Ruby object
  puts "Same object in memory"
end

The mistake happens when developers assume == means "the same object." For example, in access control:

ruby
# WRONG: relies on object identity
def authorized?(user)
  user.equal?(current_user)  # Fails if objects are different instances
end

# CORRECT: uses value comparison
def authorized?(user)
  user == current_user  # Works with any loaded instance
end

# ALSO CORRECT: uses IDs
def authorized?(user)
  user.id == current_user.id
end

Always use == for business logic comparisons. Use equal? only when you actually need to verify object identity, which is rare in Rails applications.

Monkey patching core classes

Monkey patching—adding methods to existing classes—is tempting in Ruby. Rails itself uses this extensively. But when your code monkey patches core classes like String, Array, or Hash, you risk name collisions, unexpected behavior, and maintenance nightmares.

A common pattern emerges in older codebases:

ruby
# In config/initializers/string_extensions.rb
class String
  def truncate_words(count)
    words = split(' ')
    return self if words.length <= count
    words.first(count).join(' ') + '...'
  end
end

# Usage
"This is a long sentence".truncate_words(2)  # "This is..."

This works, but creates problems:

  1. If a gem adds its own truncate_words method with different behavior, you have a conflict.
  2. New team members don't know where the method comes from when reading code.
  3. Testing becomes harder because the behavior is global.
  4. Future Ruby versions might add a built-in method that conflicts.

Modern Rails development avoids this pattern. Here are better approaches:

ruby
# Pattern 1: Create a utility module (preferred)
module TextFormatter
  def self.truncate_words(text, count)
    words = text.split(' ')
    return text if words.length <= count
    words.first(count).join(' ') + '...'
  end
end

# Usage
TextFormatter.truncate_words("This is a long sentence", 2)

# Pattern 2: Refinements (Ruby 2.1+, scoped monkey patching)
module WordTruncation
  refine String do
    def truncate_words(count)
      words = split(' ')
      return self if words.length <= count
      words.first(count).join(' ') + '...'
    end
  end
end

# Only affects code that uses the refinement:
class TextService
  using WordTruncation
  
  def format(text)
    text.truncate_words(2)  # Works here
  end
end

# Pattern 3: Wrap core classes
class FormattedString
  def initialize(text)
    @text = text
  end
  
  def truncate_words(count)
    words = @text.split(' ')
    return @text if words.length <= count
    words.first(count).join(' ') + '...'
  end
  
  # Delegate other methods to @text
  def to_s
    @text
  end
end

# Usage
FormattedString.new("This is a long sentence").truncate_words(2)

If you absolutely must add methods to core classes, use refinements to scope the changes. Better yet, create utility modules or wrapper classes. This keeps your codebase explicit and maintainable.

Common mistakes summary

These four mistakes share a common theme: they exploit Ruby's flexibility in ways that create subtle bugs. Rails adds another layer by introducing database queries, lazy loading, and implicit type coercions. The best defense is consistency in your team's patterns and careful code review.

Always ask yourself: Is my code explicit about what it assumes? Can another developer understand the intent six months from now? Will this behavior hold if the data changes or if we upgrade dependencies?

Key takeaways

  • Use safe navigation (&.) or try to handle potential nil values; never assume ActiveRecord queries return objects.
  • Remember that string literals are frozen in modern Rails; use String.new() or + concatenation for mutable strings.
  • Choose == for value comparison and equal? only for true object identity checks; most Rails code should use ==.
  • Avoid monkey patching core classes; use utility modules, refinements, or wrapper classes instead to keep intent clear.
  • Prioritize explicit code over clever code; your future self and your team will thank you when debugging at 2 AM.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles