
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:
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")$ 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:
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:
$ 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:
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)$ 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:
a = nil
a ||= 1 # => 1
a ||= 2 # => 1
b = 0
b ||= 2 # => 0Ruby’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:
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 # => 42The 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:
def result
@result ||= compute
endShort, 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:
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:
def result
return @result if defined?(@result)
@result = compute
endThe 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:
def result
return @result if instance_variable_defined?(:@result)
@result = compute
endThe 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:
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:
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 # => 3Three calls, three computations. Change compute to return false and the count is the same. The fix tests the variable rather than the value:
def result
return @result if defined?(@result)
@result = compute
endlookup = Lookup.new
3.times { lookup.result }
lookup.calls # => 1An 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:
def change_params(num)
@params ||= num
end
change_params(4) # => 4
change_params(8) # => 4That 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:
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 # => 3One 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:
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 # => 2The shortest form skips the block:
def price_for(sku)
@prices ||= {}
@prices[sku] ||= fetch_price(sku)
endIt 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)
Point = Data.define(:x, :y) do
def distance
@distance ||= Math.sqrt(x**2 + y**2)
end
end
Point.new(1, 2).distance$ 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:
class Invoice
def initialize(lines)
@lines = lines
end
def total
@total ||= @lines.sum
end
end
Invoice.new([1, 2]).freeze.total$ 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:
class EagerInvoice
attr_reader :total
def initialize(lines)
@lines = lines
@total = @lines.sum
end
end
EagerInvoice.new([1, 2]).freeze.total # => 3For Data, define initialize with keyword arguments, set the instance variable, and then call super, because super is where the freeze happens:
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? # => trueMemoize before freezing. A ||= whose variable is already set never assigns, so calling the method once and freezing afterwards is safe:
invoice = Invoice.new([1, 2])
invoice.total # => 3
invoice.freeze
invoice.total # => 3Keep 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:
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 # => 1Not 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)
class Order
def customer
@customer ||= user.profile
end
def user
nil
end
end
Order.new.customer$ 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:
order = Order.new
order.customer rescue nil
order.customer rescue nil
order.instance_variable_defined?(:@customer) # => falseThe 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:
def customer
return @customer if defined?(@customer)
@customer = user&.profile
endorder = Order.new
order.customer # => nil
order.customer # => nil
order.instance_variable_defined?(:@customer) # => trueMemoizing 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:
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:
threads = 5.times.map { Thread.new { Settings.current } }
threads.each(&:join)
Settings.loads # => 5Five threads, five loads: every thread read nil before any of them assigned. The fix is a Mutex around the whole read-and-write:
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 # => 1The 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:
# 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
endThe 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:
# app/helpers/dashboard_helper.rb
module DashboardHelper
def banner_text
@banner_text ||= begin
$banner_builds = ($banner_builds || 0) + 1
"Welcome"
end
end
endFor contrast, a class-level memo on a model persists for the life of the process:
# app/models/settings.rb
class Settings
def self.current
@current ||= begin
$settings_loads = ($settings_loads || 0) + 1
{ theme: "dark" }
end
end
endA view that calls each of them twice:
<%# 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:
# 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$ 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:
$ 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:
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 | |
|---|---|---|
| Scope | One object, usually one request | A store shared across requests and processes |
| Expiry | Never; the value dies with the object | expires_in:, or an explicit delete |
| Key | An instance variable name | A string key you choose |
nil and false results | Recomputed on every call | Cached 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:
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 # => 4Once 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:
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 # => 4nil 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:
require "memo_wise"
class Settings
prepend MemoWise
def self.current
{ theme: "dark" }
end
memo_wise self: :current
endMemoist predates it and has the same shape with extend and a trailing memoize:
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 # => 4Memoist 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)
$ 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:
attr_reader :result
def initialize
@result = do_expensive_calculation
endThe 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:
def expensive_calculation
@expensive_calculation ||= do_expensive_calculation
endNothing 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):
| Variant | Calls per second | Time per call |
|---|---|---|
| Plain method, no memoization | 22.93M | 44 ns |
@x ||= … | 21.22M | 47 ns |
defined?(@x) guard | 20.51M | 49 ns |
| memo_wise | 14.12M | 71 ns |
| Memoist | 4.52M | 221 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:
def calculation(a, b)
a + b + Time.now.to_i
endCache 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.fetchwith memoization. - Data structures: a
Setfor membership tests, aHashfor lookup by key, anArrayfor 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?
- Subscribe to our Ruby Magic newsletter and never miss an article again.
- Start monitoring your Ruby app with AppSignal.
- Share this article on social media

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 OlowodeBecome our next author!
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!


