Ruby

A Guide to Memoization in Ruby

A Guide to Memoization in Ruby

Memoization in Ruby caches a method’s return value in an instance variable so later calls skip the work: @total ||= compute_total. The ||= operator runs its right-hand side only when the variable is nil or false, so a nil or false result is recomputed on every call — guard those with defined?(@total) instead.

That is the short version. The rest is the detail: how ||= evaluates, when defined? is the better guard, and memoizing methods that take arguments. It also covers what happens on frozen objects and at the class level, where memoization stops in a Rails request, and when a cache store is the better tool. Every snippet here runs on Ruby 3.4.

An Introduction to Memoization in Ruby

Memoization stores the result of a method the first time it runs and hands back that stored result on every later call. It pays off when the work is expensive, the result stays the same for the lifetime of the object, and the method is called more than once.

An anagram finder shows the shape. Dictionary takes a word list, builds an index that groups words by their sorted letters, and looks anagrams up in it:

Ruby
class Dictionary
  def initialize(words)
    @words = words
  end
 
  def anagrams_of(word)
    index[word.chars.sort.join] - [word]
  end
 
  private
 
  def index
    puts "building the index"
    @words.each_with_object(Hash.new { |h, k| h[k] = [] }) do |word, h|
      h[word.chars.sort.join] << word
    end
  end
end
 
dictionary = Dictionary.new(%w[spar rasp pars make kame tea eat ate])
p dictionary.anagrams_of("rasp")
p dictionary.anagrams_of("kame")
p dictionary.anagrams_of("eat")
Shell
$ ruby dictionary.rb
building the index
["spar", "pars"]
building the index
["make"]
building the index
["tea", "ate"]

Every lookup rebuilds the index, and the index never changes, because the word list was fixed in initialize. To memoize index, store its result in an instance variable with ||=; a multi-line right-hand side goes inside begin … end:

Ruby
class MemoizedDictionary
  def initialize(words)
    @words = words
  end
 
  def anagrams_of(word)
    index[word.chars.sort.join] - [word]
  end
 
  private
 
  def index
    @index ||= begin
      puts "building the index"
      @words.each_with_object(Hash.new { |h, k| h[k] = [] }) do |word, h|
        h[word.chars.sort.join] << word
      end
    end
  end
end
 
dictionary = MemoizedDictionary.new(%w[spar rasp pars make kame tea eat ate])
p dictionary.anagrams_of("rasp")
p dictionary.anagrams_of("kame")
p dictionary.anagrams_of("eat")

The first call finds @index unset, builds the index, and stores it. Every call after that finds @index holding a hash and skips the block:

Shell
$ ruby memoized_dictionary.rb
building the index
["spar", "pars"]
["make"]
["tea", "ate"]

An eight-word list makes the saving invisible. Run the same two classes, with the puts line removed, over every four-letter string from "aaaa" to "zzzz" (456,976 words), 10 lookups each:

Ruby
require "benchmark"
 
words = ("aaaa".."zzzz").to_a
plain = Dictionary.new(words)
memoized = MemoizedDictionary.new(words)
 
unmemoized_time = Benchmark.realtime { 10.times { plain.anagrams_of("rasp") } }
memoized_time = Benchmark.realtime { 10.times { memoized.anagrams_of("rasp") } }
 
puts format("unmemoized: %.2fs, memoized: %.2fs", unmemoized_time, memoized_time)
Shell
$ ruby benchmark_dictionary.rb
unmemoized: 6.55s, memoized: 0.68s

That is 10 index builds against one: the memoized run is a single build of about 0.6 seconds followed by nine hash lookups, on a laptop-class CPU in Docker. The ratio belongs to this dataset and call count, not to memoization in general; the Analysing Potential Performance Improvements section covers measuring your own code.

How ||= Works in Ruby

a ||= b behaves like a || (a = b). Ruby reads a first, and only when it is nil or false does it evaluate b and assign the result. Every other value is truthy, including 0 and "", so those never trigger the assignment:

Ruby
a = nil
a ||= 1 # => 1
a ||= 2 # => 1
 
b = 0
b ||= 2 # => 0

Ruby’s syntax documentation lists ||= with the other abbreviated assignment operators.

Memoization relies on a second rule: an instance variable that was never assigned reads as nil. On a fresh object, @total ||= compute_total sees nil and runs the computation; from then on, it sees the stored value. Reading such a variable directly is fine as well. Ruby 2.7 printed warning: instance variable @total not initialized for that read under -w; Ruby 3.0 removed the warning, so there is nothing to silence on Ruby 3.4.

The assignment happens at most once per object. Once the variable holds a truthy value, ||= reads it and stops:

Ruby
class Report
  attr_reader :calls
 
  def initialize
    @calls = 0
  end
 
  def total
    @total ||= compute_total
  end
 
  private
 
  def compute_total
    @calls += 1
    42
  end
end
 
report = Report.new
report.total # => 42
report.total # => 42
report.calls # => 1
report.freeze.total # => 42

The last line is why memoizing before freezing is safe. A frozen object rejects new instance variables, but a ||= whose variable is already set never assigns, so nothing is written.

||= vs defined?

Three guards do the same job and differ in what they test.

||= tests the value:

Ruby
def result
  @result ||= compute
end

Short, and wrong whenever compute can return nil or false: the guard sees a falsy value and runs the computation again, as the next section shows.

defined? tests whether the variable exists. defined?(@result) returns nil before the first assignment and the string "instance-variable" afterwards, even when the stored value is nil:

Ruby
class Probe
  def status
    defined?(@result)
  end
 
  def store(value)
    @result = value
  end
end
 
probe = Probe.new
probe.status # => nil
probe.store(nil)
probe.status # => "instance-variable"

That makes the early return correct for any value, including nil and false:

Ruby
def result
  return @result if defined?(@result)
 
  @result = compute
end

The same guard fits on one line as defined?(@result) ? @result : (@result = compute). When the variable name is built at runtime, instance_variable_defined? does the same check as a method call that takes a symbol:

Ruby
def result
  return @result if instance_variable_defined?(:@result)
 
  @result = compute
end

The rule: ||= when the value can never be falsy, defined? otherwise. Neither guard costs measurable time. On a trivial method, the plain call, the ||= version, and the defined? version run within each other’s error bars; the numbers are in the Analysing Potential Performance Improvements section. Ruby documents both defined? and instance_variable_defined?.

Memoization Mistakes to Avoid in Your Ruby Application

Ignoring the False Or Nil Return

|| returns its left operand when that operand is truthy and evaluates the right operand otherwise. nil and false are the only falsy values in Ruby:

Ruby
2 || 4 + 5 # => 2
nil || 4 + 5 # => 9
false || 4 + 5 # => 9

||= inherits that rule. A memoized method whose result is nil or false recomputes on every call, and a counter makes that visible:

Ruby
class Lookup
  attr_reader :calls
 
  def initialize
    @calls = 0
  end
 
  def result
    @result ||= compute
  end
 
  private
 
  def compute
    @calls += 1
    nil
  end
end
 
lookup = Lookup.new
3.times { lookup.result }
lookup.calls # => 3

Three calls, three computations. Change compute to return false and the count is the same. The fix tests the variable rather than the value:

Ruby
def result
  return @result if defined?(@result)
 
  @result = compute
end
Ruby
lookup = Lookup.new
3.times { lookup.result }
lookup.calls # => 1

An older form of this fix keeps @result ||= compute after the defined? guard. It works, but the guard already returned for every call after the first, so the ||= never has a second chance to run. Write the plain assignment.

Passing Parameters to a Method

||= stores one value per instance variable and knows nothing about arguments. Memoize a method that takes one, and the first argument wins forever:

Ruby
def change_params(num)
  @params ||= num
end
 
change_params(4) # => 4
change_params(8) # => 4

That is memoization working as designed, on the wrong key. To cache per argument, keep a hash and use the argument as the key. Hash.new with a block computes and stores a value the first time a key is missing:

Ruby
class Catalog
  attr_reader :calls
 
  def initialize
    @calls = 0
  end
 
  def price_for(sku)
    @prices ||= Hash.new { |cache, key| cache[key] = fetch_price(key) }
    @prices[sku]
  end
 
  private
 
  def fetch_price(sku)
    @calls += 1
    sku == "free" ? nil : sku.length * 10
  end
end
 
catalog = Catalog.new
catalog.price_for("abc") # => 30
catalog.price_for("abc") # => 30
catalog.price_for("abcd") # => 40
catalog.calls # => 2
catalog.price_for("free") # => nil
catalog.price_for("free") # => nil
catalog.calls # => 3

One computation per distinct argument. A hash checks whether a key is present, not whether its value is truthy, so the nil price for "free" is cached too. For several arguments, use an array as the key:

Ruby
class Distance
  attr_reader :calls
 
  def initialize
    @calls = 0
  end
 
  def between(a, b)
    @between ||= Hash.new { |cache, key| cache[key] = compute(*key) }
    @between[[a, b]]
  end
 
  private
 
  def compute(a, b)
    @calls += 1
    (a - b).abs
  end
end
 
distance = Distance.new
distance.between(1, 4) # => 3
distance.between(1, 4) # => 3
distance.between(4, 5) # => 1
distance.calls # => 2

The shortest form skips the block:

Ruby
def price_for(sku)
  @prices ||= {}
  @prices[sku] ||= fetch_price(sku)
end

It works, but it puts ||= back in charge of each value. That re-inherits the falsy trap: a nil price is recomputed on every call for that key.

Two gems, Memoist and memo_wise, handle the argument bookkeeping for you; the Memoization Gems section covers both.

can't modify frozen Point: #<data Point x=1, y=2> (FrozenError)

Ruby
Point = Data.define(:x, :y) do
  def distance
    @distance ||= Math.sqrt(x**2 + y**2)
  end
end
 
Point.new(1, 2).distance
Shell
$ ruby point.rb
point.rb:3:in 'Point#distance': can't modify frozen Point: #<data Point x=1, y=2> (FrozenError)
	from point.rb:7:in '<main>'

Cause: ||= is an assignment, and a frozen object refuses new instance variables. Every Data instance is frozen at the end of initialize, which is why the first distance call fails. Anything passed through Ractor.make_shareable is frozen the same way. An explicit freeze puts a plain object in the same state:

Ruby
class Invoice
  def initialize(lines)
    @lines = lines
  end
 
  def total
    @total ||= @lines.sum
  end
end
 
Invoice.new([1, 2]).freeze.total
Shell
$ ruby invoice.rb
invoice.rb:7:in 'Invoice#total': can't modify frozen Invoice: #<Invoice:0x00007ac5d9b53078 @lines=[1, 2]> (FrozenError)
	from invoice.rb:11:in '<main>'

The hex address in that message changes on every run, which is why the Data form is the one to search for.

Fix: Compute the value before the object is frozen, or keep the cache elsewhere. Three options, in order of preference:

Compute eagerly in initialize. For a plain class, assign the instance variable there and expose it with attr_reader:

Ruby
class EagerInvoice
  attr_reader :total
 
  def initialize(lines)
    @lines = lines
    @total = @lines.sum
  end
end
 
EagerInvoice.new([1, 2]).freeze.total # => 3

For Data, define initialize with keyword arguments, set the instance variable, and then call super, because super is where the freeze happens:

Ruby
EagerPoint = Data.define(:x, :y) do
  def initialize(x:, y:)
    @distance = Math.sqrt(x**2 + y**2)
    super
  end
 
  attr_reader :distance
end
 
point = EagerPoint.new(x: 3, y: 4)
point.distance # => 5.0
point.frozen? # => true

Memoize before freezing. A ||= whose variable is already set never assigns, so calling the method once and freezing afterwards is safe:

Ruby
invoice = Invoice.new([1, 2])
invoice.total # => 3
invoice.freeze
invoice.total # => 3

Keep the cache outside the object. A hash keyed by the object stores nothing on the object itself, and Data instances compare by value, so equal points share one entry:

Ruby
DISTANCES = Hash.new { |cache, point| cache[point] = Math.sqrt(point.x**2 + point.y**2) }
 
DISTANCES[Point.new(1, 2)] # => 2.23606797749979
DISTANCES[Point.new(1, 2)] # => 2.23606797749979
DISTANCES.size # => 1

Not a fix: a defined? guard. The guard reads the variable without error, and then the assignment on the first call raises the same FrozenError.

undefined method 'profile' for nil (NoMethodError)

Ruby
class Order
  def customer
    @customer ||= user.profile
  end
 
  def user
    nil
  end
end
 
Order.new.customer
Shell
$ ruby order.rb
order.rb:3:in 'Order#customer': undefined method 'profile' for nil (NoMethodError)

    @customer ||= user.profile
                      ^^^^^^^^
	from order.rb:11:in '<main>'

Cause: The right-hand side raised before anything was assigned. ||= cached nothing, so the next call evaluates user.profile again and raises again. Memoization never caches an exception; after two failing calls, @customer does not exist:

Ruby
order = Order.new
order.customer rescue nil
order.customer rescue nil
order.instance_variable_defined?(:@customer) # => false

The for nil part is the general NoMethodError shape, covered on the exceptions guide. The memoization-specific part is what happens next.

Fix: Make the dependency safe first, with user&.profile. The safe navigation operator returns nil when user is nil, and a nil result reopens the falsy trap, so pair it with the defined? guard:

Ruby
def customer
  return @customer if defined?(@customer)
 
  @customer = user&.profile
end
Ruby
order = Order.new
order.customer # => nil
order.customer # => nil
order.instance_variable_defined?(:@customer) # => true

Memoizing at the Class Level

A ||= inside a class method stores its value on the class object, not on an instance. One value, shared by every caller, alive for as long as the process runs:

Ruby
class Settings
  @loads = 0
 
  class << self
    attr_reader :loads
 
    def current
      @current ||= load_from_disk
    end
 
    private
 
    def load_from_disk
      sleep 0.01
      @loads += 1
      { theme: "dark" }
    end
  end
end
 
Settings.current # => {theme: "dark"}
Settings.current # => {theme: "dark"}
Settings.loads # => 1
Settings.instance_variable_get(:@current) # => {theme: "dark"}

The sleep stands in for a disk read. @current and @loads belong to Settings itself; every Settings.current call in the process reads the same hash. Prefer these class-level instance variables to @@ class variables, which are shared with every subclass and easy to overwrite from one of them.

Is Memoization Thread Safe in Ruby?

Instance-level memoization is safe in practice, because each request or thread usually has its own object and nobody else can see its instance variables. Class-level memoization is different: the class is shared, and @current ||= load_from_disk is a read followed by a write, not one atomic step. The Global VM Lock (GVL) does not help, because the computation gives up the GVL whenever it sleeps or waits on I/O. In a fresh process, call Settings.current from five threads at once:

Ruby
threads = 5.times.map { Thread.new { Settings.current } }
threads.each(&:join)
Settings.loads # => 5

Five threads, five loads: every thread read nil before any of them assigned. The fix is a Mutex around the whole read-and-write:

Ruby
class Settings
  LOCK = Mutex.new
 
  class << self
    def current
      LOCK.synchronize { @current ||= load_from_disk }
    end
  end
end
 
threads = 5.times.map { Thread.new { Settings.current } }
threads.each(&:join)
Settings.loads # => 1

The first thread into synchronize loads and assigns; the other four wait, then find @current set. When the computation is idempotent and cheap, the duplicate work is harmless and the lock can be skipped. The assignment still races, though, and the last thread to finish wins. Threads and the GVL are covered in Ruby Magic: Concurrency.

Memoization in Rails

Everything in this section was checked in a fresh rails new app on Rails 8.1.

Memoization Ends With the Request

A controller gets a new instance per request, so a memoized method on it is computed once per request and again on the next. The classic current_user pattern works this way:

Ruby
# app/controllers/dashboard_controller.rb
class DashboardController < ApplicationController
  helper_method :current_account
 
  def show
  end
 
  private
 
  def current_account
    @current_account ||= begin
      $account_loads = ($account_loads || 0) + 1
      { name: "acme" }
    end
  end
end

The global counters are for the demonstration; they survive across requests in the test process, which is what makes the boundary visible. A helper method memoizes the same way, on the view context, which Rails rebuilds for every render:

Ruby
# app/helpers/dashboard_helper.rb
module DashboardHelper
  def banner_text
    @banner_text ||= begin
      $banner_builds = ($banner_builds || 0) + 1
      "Welcome"
    end
  end
end

For contrast, a class-level memo on a model persists for the life of the process:

Ruby
# app/models/settings.rb
class Settings
  def self.current
    @current ||= begin
      $settings_loads = ($settings_loads || 0) + 1
      { theme: "dark" }
    end
  end
end

A view that calls each of them twice:

erb
<%# app/views/dashboard/show.html.erb %>
<p><%= current_account[:name] %> <%= current_account[:name] %></p>
<p><%= banner_text %> <%= banner_text %></p>
<p><%= Settings.current[:theme] %> <%= Settings.current[:theme] %></p>

An integration test makes two requests and counts:

Ruby
# test/integration/dashboard_memo_test.rb
require "test_helper"
 
class DashboardMemoTest < ActionDispatch::IntegrationTest
  test "instance memoization lasts one request; class-level memoization survives" do
    $account_loads = $banner_builds = $settings_loads = 0
 
    2.times { get "/dashboard" }
 
    assert_equal 2, $account_loads
    assert_equal 2, $banner_builds
    assert_equal 1, $settings_loads
  end
end
Shell
$ bin/rails test test/integration/dashboard_memo_test.rb
1 runs, 3 assertions, 0 failures, 0 errors, 0 skips

Two loads for the controller memo, two for the helper, one for the class. Within a request, each memoized method ran once no matter how many times the view called it. Instance memoization in Rails is a per-request cache and nothing more.

Class-Level Memoization and Code Reloading

The class-level memo has a second boundary in development. The reloader throws the class away when a file changes, and the memo goes with it:

Shell
$ bin/rails runner 'Settings.current; Settings.current; puts $settings_loads; Rails.application.reloader.reload!; Settings.current; puts $settings_loads'
1
2

That is reloading in development with config.enable_reloading = true. In production, nothing reloads, and a class-level memo never expires: whatever Settings.current loaded at the first call is what every request sees until the process restarts. If the value can change while the process lives, it is not a memoization candidate. It belongs in the cache store.

Memoization vs. Rails.cache.fetch

Rails.cache.fetch reads a key from the configured store and runs the block to fill it on a miss. Three Converter instances, one fetch:

Ruby
class RatesClient
  def self.fetch_all
    $rates_fetches = ($rates_fetches || 0) + 1
    { "EUR" => 1.08 }
  end
end
 
class Converter
  def exchange_rates
    Rails.cache.fetch("exchange_rates", expires_in: 1.hour) do
      RatesClient.fetch_all
    end
  end
end
 
3.times { Converter.new.exchange_rates }
$rates_fetches # => 1
Rails.cache.read("exchange_rates") # => {"EUR" => 1.08}

The two tools answer different questions:

Instance memoization (||=)Rails.cache.fetch
ScopeOne object, usually one requestA store shared across requests and processes
ExpiryNever; the value dies with the objectexpires_in:, or an explicit delete
KeyAn instance variable nameA string key you choose
nil and false resultsRecomputed on every callCached by default

That last row is the opposite of ||=, and it is easy to get backwards. fetch stores whatever the block returns, nil included; skip_nil: true opts out for nil only:

Ruby
runs = 0
2.times { Rails.cache.fetch("nothing") { runs += 1; nil } }
runs # => 1
 
2.times { Rails.cache.fetch("flag") { runs += 1; false } }
runs # => 2
 
2.times { Rails.cache.fetch("skipped", skip_nil: true) { runs += 1; nil } }
runs # => 4

Once expires_in has elapsed, the next fetch runs the block again. A fresh rails new app on this version configures :memory_store for development, so these calls cache in development too. The low-level caching guide covers the API, and Rails’ built-in cache stores compares memory, file, Memcached, and Redis stores.

Memoization Gems: Memoist and memo_wise

When a class memoizes several methods, some with arguments, the hand-written guards and hashes add up. Two gems package them.

memo_wise is the maintained one (1.x, released in 2025). prepend MemoWise, then put memo_wise in front of a def:

Ruby
require "memo_wise"
 
class Report
  prepend MemoWise
  attr_reader :calls
 
  def initialize
    @calls = 0
  end
 
  memo_wise def total
    @calls += 1
    nil
  end
 
  memo_wise def price_for(sku)
    @calls += 1
    sku.length * 10
  end
end
 
report = Report.new
report.total # => nil
report.total # => nil
report.price_for("abc") # => 30
report.price_for("abc") # => 30
report.calls # => 2
 
report.reset_memo_wise(:total)
report.total # => nil
report.calls # => 3
 
report.reset_memo_wise
report.price_for("abc") # => 30
report.calls # => 4

nil and false results are cached, arguments are keyed automatically, reset_memo_wise(:total) clears one method, and a bare reset_memo_wise clears them all. Class methods take a self: option:

Ruby
require "memo_wise"
 
class Settings
  prepend MemoWise
 
  def self.current
    { theme: "dark" }
  end
  memo_wise self: :current
end

Memoist predates it and has the same shape with extend and a trailing memoize:

Ruby
require "memoist"
 
class Report
  extend Memoist
  attr_reader :calls
 
  def initialize
    @calls = 0
  end
 
  def total
    @calls += 1
    nil
  end
  memoize :total
 
  def price_for(sku)
    @calls += 1
    sku.length * 10
  end
  memoize :price_for
end
 
report = Report.new
report.total # => nil
report.total # => nil
report.price_for("abc") # => 30
report.price_for("abc") # => 30
report.calls # => 2
 
report.flush_cache(:total)
report.total # => nil
report.calls # => 3
 
report.flush_cache
report.price_for("abc") # => 30
report.calls # => 4

Memoist 0.16.2 runs on Ruby 3.4, caches nil, handles arguments, and clears with flush_cache. Its last release was in December 2019; the repository is open, not archived, but its main branch has not changed since December 2020. It was extracted from ActiveSupport::Memoizable, the removed Rails module covered in the next entry.

On speed, both are fast enough that the choice is about features. On the benchmark in the Analysing Potential Performance Improvements section, memo_wise runs about 1.6 times slower than a plain method call, and Memoist about five times slower, with both still at millions of calls per second.

uninitialized constant ActiveSupport::Memoizable (NameError)

Shell
$ ruby -ractive_support -e 'ActiveSupport::Memoizable'
-e:1:in '<main>': uninitialized constant ActiveSupport::Memoizable (NameError)

Cause: ActiveSupport::Memoizable was deprecated in ActiveSupport 3.2 and removed in 4.0. The file active_support/memoizable.rb ships with a deprecation warning in 3.2.22.5 and is absent from 4.0.13. Code written as extend ActiveSupport::Memoizable with memoize :total fails to load on every Rails version still in use. A require "active_support/memoizable" fails one step earlier, with cannot load such file -- active_support/memoizable (LoadError).

Fix: Memoist is the extraction of that module with the same memoize API, so extend Memoist is a drop-in replacement. memo_wise is the maintained alternative. For a single method with no arguments, plain ||= or a defined? guard needs no gem at all.

When To Memoize — and When Not To

Expensive Operations

When every instance needs the value and computing it up front is acceptable, compute it in initialize and expose it with attr_reader:

Ruby
attr_reader :result
 
def initialize
  @result = do_expensive_calculation
end

The work happens once, at construction, no memoizing method is needed, and the object can be frozen afterwards; this is the eager fix from the FrozenError section. Memoization is for the other case: a calculation that might never be needed, but could be needed more than once and returns the same value each time:

Ruby
def expensive_calculation
  @expensive_calculation ||= do_expensive_calculation
end

Nothing runs until the first call, and the result is reused from then on.

Analysing Potential Performance Improvements

Memoize after measuring. Benchmark.realtime returns the wall-clock seconds a block took, which is how the Dictionary numbers were produced. Benchmark.measure returns user, system, total, and real time when you want the split, and Benchmarking Ruby Code walks through both. Time the method before and after, with the call count your code makes, and keep the memoization when the saving outweighs the state you added.

The guard itself is free. Measured with benchmark-ips on an already-memoized trivial method, on a laptop-class CPU in Docker (Ruby 3.4.10, two-second warmup, five-second run):

VariantCalls per secondTime per call
Plain method, no memoization22.93M44 ns
@x ||= …21.22M47 ns
defined?(@x) guard20.51M49 ns
memo_wise14.12M71 ns
Memoist4.52M221 ns

benchmark-ips reports the first three as within each other’s error bars, so choose a guard for correctness, not speed. The gems add a dispatch layer and still run at millions of calls per second. Memoization pays only when the computation is expensive and repeated; a guard around a cheap method adds a line of state and buys nothing.

Changing Parameters

If the inputs change on every call, there is nothing to reuse, and a memoized result goes stale:

Ruby
def calculation(a, b)
  a + b + Time.now.to_i
end

Cache this result and every later call returns a wrong answer, because Time.now moved on. Memoization suits pure functions: the same arguments always produce the same value, and nothing outside the arguments (the clock, randomness, a database row that can change) feeds into the result.

Profiling Before You Memoize

Memoization is one optimization among several, and it is the right one only when a profile shows that a repeated computation is where the time goes.

What Is Code Optimization?

Code optimization makes a program do the same work with less time, less memory, or sometimes less code. Memory consumption during an expensive calculation and execution speed are the usual targets; codebase size is a bonus when it comes along. Optimizing without a measurement tends to move the cost somewhere else, which is why profiling comes first.

What Is Profiling?

Profiling measures where a program spends its time and memory: how often each method is called and for how long, which methods dominate the total, what the call stack looks like, and how many database queries a page needs before it renders. That tells you where an optimization would be felt and where it would be noise. Profilers such as stackprof and ruby-prof show where the time goes in a single run. To see the same for one method across production requests, AppSignal’s method instrumentation wraps it with appsignal_instrument_method and times it inside every request that calls it.

Code Optimization Methods for Ruby and Rails

  • Removing N+1 queries: Bullet and Prosopite detect them in development and test.
  • Static analysis: RuboCop and RubyCritic flag duplication, unused variables and arguments, and complexity hot spots.
  • Caching: Rails ships page, fragment, and low-level caching; the Memoization in Rails section compares Rails.cache.fetch with memoization.
  • Data structures: a Set for membership tests, a Hash for lookup by key, an Array for ordered traversal; the right choice changes the complexity class of a loop.
  • Memoization: one computation per object per value, as this guide covers.

Wrapping Up

Most memoization bugs trace back to one fact: ||= tests the value, not the variable. The rest is choosing where the cache lives. A hash when the method takes arguments, a Mutex when the cache sits on a class that threads share, eager computation when the object will be frozen, and Rails.cache when the value has to outlive a request or a process. Measure first; memoization that saves nothing you can time is state you now have to reason about.

Happy memoizing!

P.S. If you’d like to read Ruby Magic posts as soon as they get off the press, subscribe to our Ruby Magic newsletter and never miss a single post!

Frequently asked questions

What does ||= do in Ruby?
The ||= operator assigns only when the variable on its left is nil or false; a ||= b behaves like a || (a = b). Memoization uses it to store a method’s result in an instance variable on the first call and return that stored value afterwards.
Why does memoization not work with nil or false in Ruby?
Because ||= tests truthiness, not whether the variable was ever set. When the cached result is nil or false, the right-hand side runs again on every call. Check the variable itself with defined?(@value) or instance_variable_defined?(:@value), or use a gem such as memo_wise that caches those results correctly.
How do I memoize a method with arguments in Ruby?
Cache one result per argument in a hash. Use Hash.new with a block that computes and stores the value for each new key, then look the argument up; use an array as the key for several arguments. A hash caches nil too, so the falsy trap does not apply.
Is memoization thread safe in Ruby?
Instance-level memoization is safe because each request or thread usually has its own object. Class-level ||= is not atomic: several threads can run the computation at once and each assigns its own result. Wrap the assignment in a Mutex, or accept duplicate work when it is idempotent and cheap.
What is the difference between memoization and Rails caching?
Memoization stores a value in a Ruby object’s instance variable, so it lives as long as that object, usually one request, and never expires. Rails.cache.fetch stores the value in a shared store such as memory, Redis, or Memcached, with keys and expiry, and shares it across requests and processes.

Published , Updated

Wondering what you can do next?

  • Share this article on social media
Abiodun Olowode

Abiodun Olowode

Our guest author Abiodun is a software engineer who works with Ruby/Rails and React. She is passionate about sharing knowledge via writing/speaking and spends her free time singing, binge-watching movies, and watching football games.

All articles by Abiodun Olowode

Become our next author!

Find out more
$appsignal install

AppSignal monitors your apps

AppSignal provides insights for Ruby, Rails, Elixir, Phoenix, Node.js, Express and many other frameworks and libraries. We are located in beautiful Amsterdam. We love stroopwafels. If you do too, let us know. We might send you some!

Discover AppSignal