← Back to blog

Memoization in Ruby: ||= and Its Sharp Edges

· Lachlan Young

Almost every Ruby codebase has a method that looks like this:

def current_user
  @current_user ||= User.find_by(id: session[:user_id])
end

The idea is simple. Compute the value once, store it in an instance variable, and return the stored copy on every later call. The ||= operator makes it a one-liner. It is one of the first patterns a Ruby developer learns, and it is also one of the easiest to get subtly wrong.

The pattern works for the common case. It also has three sharp edges that bite in production, and a fourth problem that has nothing to do with ||= at all. Let’s walk through them.

What ||= actually expands to

x ||= y is not a single operation. It expands to:

x || (x = y)

So @current_user ||= User.find_by(...) is really:

@current_user || (@current_user = User.find_by(...))

Read it out loud. “If @current_user is truthy, return it. Otherwise, assign the result of the lookup and return that.” The lookup only runs when @current_user is falsy.

That word, falsy, is the whole problem. Ruby has exactly two falsy values: nil and false. The ||= pattern does not memoize “has this been computed yet”. It memoizes “is the stored value truthy”. Those are different questions, and the difference is where the bugs live.

Sharp edge one: memoizing nil

Look again at current_user. If session[:user_id] points at a user who does not exist, find_by returns nil. So:

def current_user
  @current_user ||= User.find_by(id: session[:user_id])
end

The first call runs the query, gets nil, and stores nil in @current_user. The second call sees @current_user is nil, which is falsy, so it runs the query again. And the third call. And every call after that.

The memoization silently does nothing. You do not get a crash, you get a method that hits the database on every single call while looking exactly like a method that caches. On a request that calls current_user twenty times, that is twenty queries instead of one.

If the value you are caching can legitimately be nil, ||= is the wrong tool.

Sharp edge two: memoizing false

The same trap, with a value that is even easier to miss:

def admin?
  @admin ||= expensive_permission_check(user)
end

If expensive_permission_check returns false, that false is stored, seen as falsy on the next call, and the expensive check runs again. For a predicate method this is almost guaranteed to happen, because half the time the answer is false.

Worse than the nil case, this one can produce wrong answers if the underlying state changes between calls, since you are recomputing when you promised not to.

The fix: ask the right question

The reliable way to memoize is to check whether the instance variable has been defined, not whether it is truthy. Ruby gives you defined? for exactly this:

def current_user
  return @current_user if defined?(@current_user)

  @current_user = User.find_by(id: session[:user_id])
end

defined?(@current_user) returns "instance-variable" (a truthy string) once the variable has been assigned, even if it was assigned nil or false. Before the first assignment it returns nil. So the first call falls through and computes, every later call returns the stored value, whatever that value is.

This is more verbose than ||=, and that is the trade. Use ||= when the value can never legitimately be nil or false. Use the defined? guard when it can. If you are not sure, use the guard.

There is one footgun with defined?. An unassigned instance variable read elsewhere is just nil and never raises, so it is easy to forget the variable is conditionally assigned. Keep the assignment and the guard in the same method and it stays readable.

Sharp edge three: methods with arguments

||= memoizes one slot. The moment your method takes an argument, one slot is not enough:

def fib(n)
  @fib ||= n < 2 ? n : fib(n - 1) + fib(n - 2)
end

fib(10)  # => 1
fib(20)  # => 1

Every call writes to the same @fib. The first call computes a value, and every later call, regardless of n, returns that first value. The cache has room for one answer and the method needs a different answer per argument.

This is where a hash earns its place. Use the argument as the key:

def fib(n)
  @fib ||= {}
  @fib[n] ||= n < 2 ? n : fib(n - 1) + fib(n - 2)
end

Now @fib is a per-instance cache, and each n gets its own entry. The recursion fills the cache bottom-up, so fib(100) is linear instead of exponential.

You can lean on Hash.new to remove the initialization line entirely. A default block that stores its result turns the hash into the memoization:

def fib_cache
  @fib_cache ||= Hash.new do |cache, n|
    cache[n] = n < 2 ? n : cache[n - 1] + cache[n - 2]
  end
end

fib_cache[30]  # => 832040, computed once per index

For multiple arguments, key the hash on an array:

def distance(from, to)
  @distance ||= {}
  @distance[[from, to]] ||= compute_distance(from, to)
end

Arrays compare and hash by value in Ruby, so [from, to] is a perfectly good composite key. The same nil/false caveat applies to the inner ||=: if compute_distance can return nil or false, use fetch instead:

def distance(from, to)
  @distance ||= {}
  key = [from, to]
  return @distance[key] if @distance.key?(key)

  @distance[key] = compute_distance(from, to)
end

@distance.key?(key) is the hash equivalent of the defined? guard. It asks “has this been computed”, not “is the answer truthy”.

The fourth problem: memoization and frozen objects

This one is not about ||= semantics, it is about where the cache lives. Memoizing into an instance variable mutates the object. If the object is frozen, the write raises:

class Point
  def initialize(x, y)
    @x, @y = x, y
    freeze
  end

  def magnitude
    @magnitude ||= Math.sqrt(@x**2 + @y**2)
  end
end

Point.new(3, 4).magnitude
# => FrozenError: can't modify frozen Point

Value objects are often frozen on purpose, and memoization fights that. The honest fix is to compute eagerly in the constructor, before the freeze:

class Point
  attr_reader :magnitude

  def initialize(x, y)
    @x, @y = x, y
    @magnitude = Math.sqrt(x**2 + y**2)
    freeze
  end
end

If the value is genuinely expensive and rarely needed, the object should probably not be frozen, or the cache should live outside it (a class-level hash keyed by the object). But reach for that only when you have measured a reason to.

When memoization is the wrong answer

Memoization trades correctness for speed. The stored value is a snapshot, and a snapshot goes stale.

def total
  @total ||= line_items.sum(&:price)
end

If something adds a line item after total has been called once, the method keeps returning the old number. Within a single web request that is usually fine, the request is short and the data does not change underneath you. Across requests, or in a long-running job, or in a test that mutates state, it is a bug.

Two questions before you memoize anything:

The takeaway

||= is a fine memoization tool for one specific shape of problem: a no-argument method whose result is always truthy, on an object that is not frozen, where the underlying data does not change. That describes a lot of real code, which is why the pattern is everywhere.

It describes far from all of it. When the result can be nil or false, guard with defined? or Hash#key?. When the method takes arguments, cache into a hash keyed by those arguments. When the object is frozen, compute up front. And before reaching for any of it, check that the thing you are caching is expensive enough to be worth a stale-data bug.

Memoization looks like a free speedup. It is actually a cache, and every cache is a correctness problem wearing a performance costume. Treat it like one.

Enjoyed this post?

Subscribe to get notified when we publish more Ruby and Rails content.