Common Mistakes and Gotchas
|
This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production. The reference material consulted while preparing these pages is listed in the section bibliography. |
This page is written against Ruby 4.0 (4.0.7, checked on 2026-10-18). It collects the mistakes that Ruby programmers keep
making, in the order in which they tend to appear: bugs that look right on the page, run without an error, and give the wrong
answer, plus a few that fail loudly but with a message that points at the wrong cause. Each section shows the broken code
with its real output, explains the rule of the language that produces it, and shows the fix. None of these is a defect in
Ruby; each one follows from a documented rule that is easy to forget: Array.new(3, x) stores the same x three times,
and binds more loosely than =, and a rescue without a class catches StandardError and nothing else.
The rules behind each section are explained in depth elsewhere in this reference, so each section links to the page that
owns the topic: Arrays and Hashes
for the first two, Operators and Precedence and
Control Expressions for and/or and truthiness,
Exceptions and Error Handling for rescue,
Method Lookup and super and
Hooks, eval and DSLs for super and hooks,
Variables, Constants and Scope for constants,
Numbers and Time and Dates
for money and clocks, Strings and
Object Lifecycle, Freezing and Copying for chilled
strings and aliasing, Pattern Matching for case/in, and
Threads and the GVL for the singleton race. For the equivalent
pitfalls in other languages, see Python (mutable default arguments),
Go and Rust, where several of these
mistakes are rejected by the compiler. The style rules that prevent many of them are collected in
Idioms, Style and Clean Code and
Style, Linting and Static Analysis.
Every example was syntax-checked with ruby -wc, run with ruby -w on Ruby 4.0.7 and linted with RuboCop 1.92 using the
configuration of the running shelf gem (double-quoted strings, NewCops: enable, Style/Documentation off). The output
shown is the real output; p prints the inspect form, so p x # => y means that y is what was printed. These
examples are deliberately wrong code, so each file carries rubocop:disable comments that name the cop that objects on
purpose; the section on what RuboCop catches lists which mistakes a cop reports and which it
cannot. Where the shelf gem is used (see the shelf gem source), the scripts
are run from the gem directory with ruby -w -Ilib.
The mistakes at a glance
| Mistake | What happens | The fix |
|---|---|---|
|
Every slot, or every missing key, shares one object. |
|
Deleting from an array inside its own |
Elements are skipped, with no error. |
|
Adding a key to a hash inside its own |
|
Build a new hash, or collect the changes and apply them afterwards. |
|
Parsed as |
Use |
|
Assigns, then tests the always-truthy |
|
|
Swallows Ctrl-C, |
|
|
The exception sails past the handler. |
Rescue the class that is really raised, or a common parent. |
|
A bare |
Name it explicitly, or raise |
|
Arguments are passed, or dropped, silently. |
Write the arguments you mean, or |
A hook such as |
The parent’s hook never runs. |
Call |
|
A warning, and the new value wins. |
Do not reassign; |
|
|
|
|
|
|
|
Binary floating point error. |
Integer cents, |
|
The wall clock can jump. |
|
Mutating a string literal |
A chilled-string warning (3.4+); an error with the magic comment. |
|
|
|
Add |
|
Two threads can build two instances. |
|
|
Assigns a local variable, not the attribute. |
|
Shared mutable defaults
Array.new(size, object) stores the same object in every slot, and Hash.new(object) returns the same object for
every missing key without storing it. Both are fine for immutable values (Array.new(3, 0), Hash.new(0)), and both are a
trap for anything you intend to mutate. The block forms run the block once per slot or once per missing key, so each gets a
fresh object. A default argument that points at a mutable constant has the same problem on the method side.
# frozen_string_literal: true
rows = Array.new(3, [])
rows[0] << "a"
p rows # => [["a"], ["a"], ["a"]]
p rows.map(&:object_id).uniq.size # => 1
shelves = Hash.new([]) # rubocop:disable Lint/SharedMutableDefault
shelves[:fiction] << "Emma"
p shelves # => {}
p shelves[:history] # => ["Emma"]
rows = Array.new(3) { [] }
rows[0] << "a"
p rows # => [["a"], [], []]
shelves = Hash.new { |hash, key| hash[key] = [] }
shelves[:fiction] << "Emma"
p shelves # => {fiction: ["Emma"]}
counts = Hash.new(0)
counts[:fiction] += 1
p counts # => {fiction: 1}
# A mutable default argument held in a constant is shared in the same way.
DEFAULT_TAGS = [] # rubocop:disable Style/MutableConstant
def tag(book, tags = DEFAULT_TAGS) = tags << book
tag("Emma")
tag("Persuasion")
p DEFAULT_TAGS # => ["Emma", "Persuasion"]
def tag_safely(book, tags = [])
tags + [book]
end
p tag_safely("Emma") # => ["Emma"]
p tag_safely("Persuasion") # => ["Persuasion"]
The array case prints four copies of "a" because the three rows are one object (object_id reports a single id). The hash
case is worse: shelves[:fiction] << "Emma" mutates the shared default array and stores nothing, so shelves stays empty
while shelves[:history] now answers ["Emma"]. The block form assigns into the hash (hash[key] = []), which is what
makes later lookups find the same array again. Hash.new(0) is safe because += assigns a new integer back with []=
instead of mutating one. RuboCop’s Lint/SharedMutableDefault reports the Hash.new([]) line; it does not report
Array.new(3, []), and Style/MutableConstant reports the unfrozen DEFAULT_TAGS, which is the real source of the third
problem: a method that appends to its default argument mutates a constant, so tag_safely builds a new array instead.
Mutating a collection while iterating over it
Array#each walks by index. Deleting an element shifts the rest down, so the index then points past the next element and
that element is never visited. A Hash refuses to grow while it is being iterated.
# frozen_string_literal: true
titles = %w[Emma Dracula Persuasion Ulysses]
titles.each { |title| titles.delete(title) if title.length > 5 }
p titles # => ["Emma", "Persuasion"]
titles = %w[Emma Dracula Persuasion Ulysses]
p(titles.reject { |title| title.length > 5 }) # => ["Emma"]
titles.reject! { |title| title.length > 5 }
p titles # => ["Emma"]
stock = { emma: 2, dracula: 0 }
begin
stock.each { |title, copies| stock[:"#{title}_new"] = copies }
rescue RuntimeError => e
puts "#{e.class}: #{e.message}"
end
stock.each { |title, copies| stock[title] = copies + 1 }
p stock # => {emma: 3, dracula: 1}
p(stock.transform_values { |copies| copies * 2 }) # => {emma: 6, dracula: 2}
Source: Array#reject!, Hash#transform_values
In the first run Dracula is deleted, Persuasion slides into the index that was just visited, the loop moves on to
Ulysses, and Persuasion is never tested: it is longer than five characters and should have been removed, but it survives.
The result depends on the data, which is what makes the bug hard to see. reject returns a new
array and reject! (or delete_if) removes safely in place. Adding a new key to a hash raises RuntimeError ("can’t add a
new key into hash during iteration"); assigning to existing keys is allowed, and transform_values or to_h with a block
is usually clearer. The same idea applies to Set and to any Enumerator built on the collection.
and, or and not versus &&, || and !
and, or and not have the lowest precedence of all operators, lower than assignment. They are control-flow operators,
meant for do_this or raise, not for boolean arithmetic.
# frozen_string_literal: true
available = true
overdue = false
ok = available and overdue
p ok # => true
ok = available && overdue
p ok # => false
ok = (available and overdue)
p ok # => false
def lookup(code) = code == "ok" ? 200 : nil
status = lookup("missing") or puts "lookup failed"
p status # => nil
copies = 3
p(not copies == 3) # rubocop:disable Style/Not, Style/InverseMethods
p !copies == 3 # => false
p copies != 3 # => false
Source: Operator precedence
ok = available and overdue parses as (ok = available) and overdue, so ok is true although the combined condition is
false; ok = (available and overdue) or ok = available && overdue is what was meant. In the same way
status = lookup("missing") or puts "lookup failed" assigns nil first and then runs puts. That is the legitimate use of
or, and it works because the or sees the already-assigned nil. not copies == 3 is not (copies == 3), but !copies == 3
is (!copies) == 3, which compares false with 3; both are false here by coincidence, so use !=. RuboCop’s
Style/AndOr flags and/or only inside conditions by default, so it does not catch the assignment case above; the style
guide’s advice is to never use them for values.
Assignment in a condition, and truthiness
Only nil and false are false. 0, "", [] and {} are truthy, which surprises programmers from C, Python or
JavaScript. Combine that with = where == was meant and the condition is always true.
# frozen_string_literal: true
copies = 1 # rubocop:disable Lint/UselessAssignment
puts "no copies left" if copies = 0 # rubocop:disable Lint/AssignmentInCondition, Lint/LiteralAssignmentInCondition
p copies # => 0
copies = 0
puts "zero is truthy in Ruby" if copies
puts "no copies left" if copies.zero?
if (match = "Emma (1815)".match(/\((\d+)\)/))
p match[1] # => "1815"
end
Output of ruby -w -Ilib f04_assign_in_condition.rb:
f04_assign_in_condition.rb:4: warning: found '= literal' in conditional, should be ==
no copies left
0
zero is truthy in Ruby
no copies left
"1815"
Source: Control Expressions
if copies = 0 assigns 0 to copies and then tests the result, 0, which is truthy, so the branch runs and the variable
is clobbered. Ruby prints found '= literal' in conditional, should be == when the right-hand side is a literal; Ruby 4.0.7
printed it twice for this line, and an assignment from a method call gets no warning at all. When the assignment is
intended, as in the match example, wrap it in parentheses; RuboCop’s Lint/AssignmentInCondition accepts that form.
Lint/LiteralAssignmentInCondition reports the literal case. To test for zero, write copies.zero? (RuboCop’s
Style/NumericPredicate prefers it to copies == 0).
Rescuing too much, or the wrong class
rescue Exception catches everything, including Interrupt (Ctrl-C), SystemExit (exit), NoMemoryError and
SignalException, so a program can no longer be stopped from the keyboard. A bare rescue (or rescue => e) catches only
StandardError and its descendants, which is almost always what you want. The opposite mistake is a handler for a class that
the code never raises.
# frozen_string_literal: true
# rubocop:disable Style/OneClassPerFile
def long_job = raise(Interrupt)
begin
long_job
rescue Exception # rubocop:disable Lint/RescueException
puts "swallowed the Ctrl-C"
end
begin
Integer("x")
rescue StandardError => e
puts "#{e.class}: #{e.message}"
end
p Interrupt.superclass # => SignalException
p SignalException.superclass # => Exception
p SystemExit.superclass # => Exception
p NotImplementedError.superclass # => ScriptError
p ScriptError.superclass # => Exception
p LoadError.superclass # => ScriptError
p NoMatchingPatternError.superclass # => StandardError
class StoreError < StandardError; end
class ParseError < StandardError; end
begin
begin
raise ParseError, "bad line"
rescue StoreError
puts "not reached"
end
rescue StandardError => e
puts "outer: #{e.class}"
end
begin
raise ParseError, "bad line"
rescue StoreError, ParseError => e
puts "both: #{e.class}"
end
# rubocop:enable Style/OneClassPerFile
Output of ruby -w -Ilib f05_rescue.rb:
swallowed the Ctrl-C
ArgumentError: invalid value for Integer(): "x"
SignalException
Exception
Exception
ScriptError
Exception
ScriptError
StandardError
outer: ParseError
both: ParseError
Source: Exception, Exceptions
The first handler swallows the Interrupt raised by long_job and the program carries on; Lint/RescueException reports
it. Integer("x") raises ArgumentError, a StandardError, which the plain handler sees. The hierarchy lines show why:
Interrupt, SystemExit, ScriptError (and with it NotImplementedError and LoadError) descend from Exception
directly. The nested example raises ParseError inside a begin that rescues only StoreError, so it propagates to the
outer handler; listing both classes in one rescue (or giving them a common parent) handles it. Note that
NoMatchingPatternError is a StandardError, whereas the SyntaxError raised by eval or load is a
ScriptError. More detail on retry, ensure and custom hierarchies is in
Exceptions and Error Handling.
NotImplementedError is not a StandardError
Abstract methods are often written as raise NotImplementedError. Because the class descends from ScriptError, the
ordinary rescue does not catch it, and a missing method implementation can crash a worker that was written to log and
carry on.
# frozen_string_literal: true
# rubocop:disable Style/OneClassPerFile
class Store
def save(_book) = raise(NotImplementedError, "#{self.class} must implement save")
end
begin
Store.new.save(1)
rescue StandardError
puts "not reached"
rescue NotImplementedError => e
puts "only an explicit rescue sees it: #{e.message}"
end
class StrictStore
def save(_book) = raise(NoMethodError, "#{self.class}#save is abstract")
end
begin
StrictStore.new.save(1)
rescue StandardError => e
puts "#{e.class}: #{e.message}"
end
p NotImplementedError.ancestors.take(3) # => [NotImplementedError, ScriptError, Exception]
p Store.new.respond_to?(:save) # => true
# rubocop:enable Style/OneClassPerFile
Output of ruby -w -Ilib f06_not_implemented.rb:
only an explicit rescue sees it: Store must implement save
NoMethodError: StrictStore#save is abstract
[NotImplementedError, ScriptError, Exception]
true
Source: NotImplementedError
The documentation describes NotImplementedError as the error for a feature that the platform does not provide, such as
fork on some systems, and not for abstract methods. Two reasonable alternatives are to leave the method undefined (a
caller then gets NoMethodError, a StandardError, and respond_to? answers false) or to raise a StandardError
subclass such as the NoMethodError shown above. Subclassing NotImplementedError is fine if every caller rescues it
explicitly. See Classes and Objects for abstract-base-class
patterns.
super versus super()
A bare super (zsuper) passes the current values of the method’s own parameters, including keyword and block arguments,
to the parent method. super() passes none, and super(args) passes exactly those.
# frozen_string_literal: true
# rubocop:disable Style/OneClassPerFile
class Base
attr_reader :title, :year
def initialize(title = "untitled", year: 0)
@title = title
@year = year
end
end
class WithImplicit < Base
def initialize(title, year: 1900)
super
end
end
class WithEmpty < Base
def initialize(title, year: 1900) # rubocop:disable Lint/UnusedMethodArgument
super()
@extra = title
end
end
class WithExplicit < Base
def initialize(title, year: 1900)
super(title.upcase, year: year)
end
end
class ReassignsFirst < Base
def initialize(title, year: 1900)
title = title.upcase
super
end
end
p WithImplicit.new("Emma").year # => 1900
p WithEmpty.new("Emma").title # => "untitled"
p WithExplicit.new("Emma").title # => "EMMA"
p ReassignsFirst.new("Emma").title # => "EMMA"
# rubocop:enable Style/OneClassPerFile
Source: Methods, including super
WithImplicit forwards title and year: to Base, so year is 1900. WithEmpty calls super(), so Base falls
back to its defaults and the title is "untitled"; RuboCop reports the parameter that is now unused
(Lint/UnusedMethodArgument). The bare super in ReassignsFirst sees the reassigned local title, because it passes the
current value of the variable, not the argument originally received: "EMMA". That is convenient, and a surprise to anyone
who expected the original argument. Two failure modes follow. A bare super in a method that has since gained parameters
that the parent does not accept raises ArgumentError; and an initialize that omits super entirely leaves the parent’s
state uninitialized, which Lint/MissingSuper reports for classes with a superclass. The full lookup rules, including
super inside define_method (where the implicit form is not allowed), are in
Method Lookup and super.
Hooks without super
inherited, included, extended, prepended, method_added and the other hooks are ordinary methods. Defining one on
your class replaces the version inherited from an ancestor, so a hook that forgets super silently disables whatever the
ancestor chain did. A second trap is class-level state: instance variables on a class are per class, not inherited.
# frozen_string_literal: true
# rubocop:disable Style/OneClassPerFile
class Plugin
def self.registry = (@registry ||= [])
def self.inherited(subclass) # rubocop:disable Lint/MissingSuper
registry << subclass
end
end
class CsvPlugin < Plugin; end
p Plugin.registry # => [CsvPlugin]
p CsvPlugin.registry # => []
class Tracked
@seen = []
class << self
attr_reader :seen
def inherited(subclass)
super
subclass.instance_variable_set(:@seen, [])
Tracked.seen << subclass
end
end
end
class JsonPlugin < Tracked; end
p Tracked.seen # => [JsonPlugin]
p JsonPlugin.seen # => []
module Audited
def self.included(base)
super
base.extend(ClassMethods)
end
module ClassMethods
def audited? = true
end
end
class Loan
include Audited
end
p Loan.audited? # => true
# rubocop:enable Style/OneClassPerFile
Source: Class#inherited, Module#included
Plugin.registry lists CsvPlugin, but CsvPlugin.registry is empty: the hook runs with self set to Plugin, the class
whose singleton method is called, so it fills the @registry variable of Plugin, and an instance variable of a class is not inherited by
its subclasses: CsvPlugin has a variable of its own that was never filled. The Tracked version shows the repair: initialise per-subclass state in the hook and call super first so that any
hook defined above Tracked, or in a module the class extends, keeps working. Lint/MissingSuper reports the first
inherited definition. The included hook follows the same rule. The default implementations of these hooks do nothing, so super is
harmless when no ancestor defines one and essential when one does. The
hook catalogue is in Hooks, eval and DSLs.
Reassigning a constant, and freezing only one level
Ruby lets you assign a constant again; it warns and the new value replaces the old. Constants are also not immutable: the
name is fixed, the object is not, and freeze is shallow.
# frozen_string_literal: true
LIMIT = 3
LIMIT = 5 # rubocop:disable Lint/ConstantReassignment
p LIMIT # => 5
NAMES = %w[Emma Dracula].freeze
begin
NAMES << "Ulysses"
rescue FrozenError => e
puts "#{e.class}: #{e.message}"
end
TAGS = [+"a", +"b"].freeze
TAGS.first << "!"
p TAGS # => ["a!", "b"]
p TAGS.frozen? # => true
p TAGS.first.frozen? # => false
SETTINGS = { color: true }.freeze
p SETTINGS.merge(color: false) # => {color: false}
p SETTINGS # => {color: true}
Output of ruby -w -Ilib f09_constants.rb:
f09_constants.rb:4: warning: already initialized constant LIMIT
f09_constants.rb:3: warning: previous definition of LIMIT was here
5
FrozenError: can't modify frozen Array: ["Emma", "Dracula"]
["a!", "b"]
true
false
{color: false}
{color: true}
Source: Assignment
The first two lines of output are the already initialized constant warning and the pointer to the previous definition;
the value is 5 afterwards. Lint/ConstantReassignment reports the second assignment statically. A frozen NAMES rejects
<< with FrozenError, but TAGS shows the limit of freeze: the array is frozen and the strings inside it are not, so
TAGS.first << "!" succeeds. With # frozen_string_literal: true string literals are frozen anyway (hence the +"a" to
build mutable ones); %w[Emma Dracula].freeze is a frozen array of frozen strings, which is why NAMES needs no
map(&:freeze). To change a frozen hash, build a new one with merge. Assigning a constant inside a method body is a
SyntaxError ("dynamic constant assignment").
x ||= value when x can be false
a ||= b means a || (a = b). It assigns when a is nil or false, which is a bug when false (or a deliberately
stored nil) is a legitimate value. 0 and "" are truthy, so they are not replaced.
# frozen_string_literal: true
config = { color: false, retries: 0, title: nil }
config[:color] ||= true
config[:retries] ||= 3
config[:title] ||= "Untitled"
p config # => {color: true, retries: 0, title: "Untitled"}
config = { color: false }
config[:color] = true unless config.key?(:color)
p config # => {color: false}
p config.fetch(:color, true) # => false
p config.fetch(:page, 1) # => 1
verbose = nil
verbose = true if verbose.nil?
p verbose # => true
def settings(options = {})
{ color: options.fetch(:color, true), page: options.fetch(:page, 1) }
end
p settings(color: false) # => {color: false, page: 1}
p settings(page: 0) # => {color: true, page: 0}
memo = {}
memo[:ready] = false
memo[:ready] ||= :recomputed
p memo # => {ready: :recomputed}
Source: Abbreviated assignment, Hash#fetch
config[:color] ||= true turns an explicit false into true, while retries stays 0. For options use fetch(key, default),
which distinguishes a missing key from a stored false or nil, or key? as in the second block. Use nil? when only
nil should be replaced. The last example repeats the mistake with a memoized flag: memo[:ready] ||= ... recomputes every
time while the stored value is false, which also makes ||= unsuitable for memoizing a method that may return false or
nil; use defined?(@x) or instance_variable_defined? for that.
puts versus p
puts writes to_s and flattens arrays, so nil, [], "" and [nil] all look nearly the same. p writes inspect,
returns its argument (so it can be dropped into the middle of an expression) and pp does the same with line wrapping.
# frozen_string_literal: true
require "shelf"
title = "Emma"
puts title
p title
puts nil
p nil
puts []
puts [nil]
puts [1, [2, 3]]
x = p(:a)
p x # => :a
y = puts("hi")
p y # => nil
isbn = Shelf::Isbn.new(digits: "978-0-306-40615-7")
puts isbn
p isbn
puts isbn.inspect
puts "multi\nline".inspect
Output of ruby -w -Ilib f11_puts_p.rb:
Emma
"Emma"
nil
1
2
3
:a
:a
hi
nil
9780306406157
#<data Shelf::Isbn digits="9780306406157">
#<data Shelf::Isbn digits="9780306406157">
"multi\nline"
Source: Kernel#puts, Kernel#p
puts nil prints an empty line, puts [] prints nothing at all (the second blank line comes from puts [nil], and
p nil is the only line that says nil), puts [1, [2, 3]] prints one element per line, and puts returns nil while p returns its argument. The Shelf::Isbn value
prints its digits through to_s with puts and its #<data ...> form with p. Use p when debugging, and Logger or an
explicit $stderr.puts for diagnostics in programs; Logger is a bundled gem in 4.0 and needs a Gemfile entry under
Bundler.
Money and floating point
Float is binary floating point, so most decimal fractions are approximations. Integer division truncates toward negative
infinity, and dividing a float by zero gives Infinity rather than an error.
# frozen_string_literal: true
require "bigdecimal"
p 0.1 + 0.2 # => 0.30000000000000004
p 0.1 + 0.2 == 0.3 # rubocop:disable Lint/FloatComparison
p (0.1 + 0.2 - 0.3).abs < Float::EPSILON # => true
p (19.99 * 100).to_i # => 1998
p BigDecimal("19.99") * 3 # => 0.5997e2
puts (BigDecimal("19.99") * 3).to_s("F")
p Rational("19.99") * 3 # => (5997/100)
price_cents = 1999
total = price_cents * 3
puts format("%<euros>d.%<cents>02d", euros: total / 100, cents: total % 100)
p 10 / 4 # => 2
p 10.fdiv(4) # => 2.5
p(-7 / 2) # => -4
p 7.0 / 0 # => Infinity
begin
7 / 0
rescue ZeroDivisionError => e
puts e.message
end
Source: Float, Rational, BigDecimal
19.99 * 100 is 1998.9999999999998, and to_i truncates it to 1998. Keep money as integer minor units (cents) and format
at the edge, or use BigDecimal("19.99") (a string literal, never a float) or Rational("19.99"). BigDecimal is a bundled
gem since Ruby 3.4, so under Bundler it needs a Gemfile entry. Compare floats with a tolerance
(Lint/FloatComparison reports ==), or Float#round, not with ==. Integer division 10 / 4 is 2; use fdiv or a
float operand for 2.5; -7 / 2 is -4 (floor), whereas remainder follows the sign of the dividend. The numeric tower is
described in Numbers.
Timing with the wall clock
Time.now follows the system clock, which can be stepped by NTP or set by the user, so it is not monotonic. A duration measured by subtracting two Time.now values can come out wrong,
or negative. The monotonic clock only moves forward.
# frozen_string_literal: true
start = Process.clock_gettime(Process::CLOCK_MONOTONIC)
sleep 0.01
elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start
p elapsed.class # => Float
p elapsed.between?(0.005, 0.5) # => true
wall = Time.now
sleep 0.01
p (Time.now - wall).class # => Float
millis = Process.clock_gettime(Process::CLOCK_MONOTONIC, :millisecond)
p millis.class # => Integer
cpu = Process.clock_gettime(Process::CLOCK_PROCESS_CPUTIME_ID)
p cpu.class # => Float
Source: Process.clock_gettime
Process.clock_gettime(Process::CLOCK_MONOTONIC) returns seconds as a Float since an unspecified start, so only differences
are meaningful; pass a unit (:millisecond, :microsecond, :float_millisecond, …) as the second argument for another
representation. CLOCK_PROCESS_CPUTIME_ID measures CPU time of the process, which excludes the time spent sleeping or
waiting for I/O. Use Time.now for timestamps (what time is it?) and the monotonic clock for elapsed time (how long did it
take?); the Benchmark library (a bundled gem in 4.0) already measures with a monotonic clock, as covered in
Performance, Benchmarking and Profiling.
Chilled strings and the frozen string literal magic comment
Until Ruby 3.3 a string literal without the magic comment was a plain mutable string. Since Ruby 3.4 it is "chilled": still mutable, but the first mutation emits a deprecation warning, preparing for a future release in which literals are frozen. Ruby 4.0 still does not freeze them by default.
# rubocop:disable Style/FrozenStringLiteralComment
title = "Emma"
title << " (1815)"
p title
p title.frozen?
# rubocop:enable Style/FrozenStringLiteralComment
Output with different flags:
$ ruby f14_chilled.rb
"Emma (1815)"
false
$ ruby -w f14_chilled.rb
f14_chilled.rb:3: warning: literal string will be frozen in the future (run with --debug-frozen-string-literal for more information)
"Emma (1815)"
false
$ ruby -W:deprecated --debug-frozen-string-literal f14_chilled.rb
f14_chilled.rb:3: warning: literal string will be frozen in the future
f14_chilled.rb:2: info: the string was created here
"Emma (1815)"
false
$ ruby --enable-frozen-string-literal f14_chilled.rb
f14_chilled.rb:3:in '<main>': can't modify frozen String: "Emma" (FrozenError)
Without a flag Ruby 4.0.7 prints no warning at all; the deprecation category is off by default and -w or
-W:deprecated turns it on (-W:no-deprecated silences it again). With --debug-frozen-string-literal the second line
points at the place where the string was created. --enable-frozen-string-literal or the magic comment turns the warning into
a FrozenError. Running the same file on Ruby 3.3.12 with -w prints no warning, and Ruby 3.4.11 prints the same text as 4.0.7. Use # frozen_string_literal: true at the top of every file, and +"text", String.new, dup or
"#{...}" interpolation (which always builds a new, unfrozen string) when a mutable string is needed. Older books and
tutorials say that literals are frozen by default from Ruby 3.4; that is incorrect, and the migration step in 3.4 is the
chilled string. The details are in Strings and
Literals.
case/in without else
case/when returns nil when nothing matches. case/in raises NoMatchingPatternError (and NoMatchingPatternKeyError
for a hash pattern whose key is missing, from the one-line => form). A guard that fails counts as no match.
# frozen_string_literal: true
def describe(value)
case value
in Integer => n if n.positive? then "positive #{n}"
in String => s then "string #{s}"
end
end
puts describe(3)
[-3, nil, [1, 2]].each do |value|
describe(value)
rescue NoMatchingPatternError => e
puts "#{e.class}: #{e.message}"
end
value = 5
begin
case value
in String then 1
end
rescue NoMatchingPatternError => e
puts e.message
end
begin
{ name: "Emma" } => { title: String }
rescue NoMatchingPatternKeyError => e
puts "#{e.class}: #{e.message}"
p e.key # => :title
end
def kind(value)
case value
in Integer then :int
in String then :str
else :other
end
end
p kind(nil) # => :other
def kind_when(value)
case value
when Integer then :int
when String then :str
end
end
p kind_when(nil) # => nil
late = ({ status: :ok } in { status: :late })
p late # => false
Output of ruby -w -Ilib f15_case_in.rb:
positive 3
NoMatchingPatternError: -3
NoMatchingPatternError: NoMatchingPatternError
NoMatchingPatternError: [1, 2]
5: String === 5 does not return true
NoMatchingPatternKeyError: {name: "Emma"}: key not found: :title
:title
:other
nil
false
Source: Pattern Matching, NoMatchingPatternError
The messages differ by situation. A plain failed case reports the value and the failing check (5: String === 5 does not
return true), the one-line hash pattern reports the missing key and exposes it as e.key, and a failed guard or an
alternative reports only the value (-3, or [1, 2]) and sometimes just the class name (the nil case). Do not parse the
message. Add an else branch, or rescue NoMatchingPatternError when failing is the intent. The boolean forms value in pattern and value => pattern are covered in Pattern Matching; in
returns true or false and never raises, => raises.
Lazy singletons and thread races
@instance ||= new looks like a harmless memoization and is not atomic. Two threads can both see nil, both run new and
each keep its own result, so the "singleton" exists twice. The window grows with the cost of initialize.
# frozen_string_literal: true
# rubocop:disable Style/OneClassPerFile
class Registry
def self.instance = @instance ||= new
def initialize = sleep(0.05)
end
ids = Array.new(4) { Thread.new { Registry.instance.object_id } }.map(&:value)
p ids.uniq.size > 1 # => true
require "singleton"
class Config
include Singleton
def initialize = sleep(0.05)
end
ids = Array.new(4) { Thread.new { Config.instance.object_id } }.map(&:value)
p ids.uniq.size # => 1
begin
Config.new
rescue NoMethodError => e
puts e.message
end
class Lazy
LOCK = Thread::Mutex.new
private_constant :LOCK
def self.instance = LOCK.synchronize { @instance ||= new }
def initialize = sleep(0.05)
end
ids = Array.new(4) { Thread.new { Lazy.instance.object_id } }.map(&:value)
p ids.uniq.size # => 1
# rubocop:enable Style/OneClassPerFile
Output of ruby -w -Ilib f16_singleton.rb:
true
1
private method 'new' called for class Config
1
Source: Singleton, Mutex#synchronize
With a slow initialize the first version produces several distinct objects. The Singleton module from the standard
library makes new private (private method 'new' called for class Config) and creates the instance under a lock, and an
explicit Mutex around @instance ||= new does the same by hand. The GVL does not help here: it makes individual operations
atomic, but a thread switch can happen between the read of @instance and the assignment, as explained in
Threads and the GVL. Prefer a constant built at load time when
the object is cheap, and injection of a collaborator (the shelf Catalog takes its store as a keyword argument) over a
global instance.
Setters need self, and other aliasing surprises
Inside a method, returned = true creates a local variable; only self.returned = true calls the setter. Variables hold
references, so assignment does not copy a string or array. Bang methods return nil when they change nothing.
# frozen_string_literal: true
class Loan
attr_accessor :returned
def initialize = @returned = false
def close
returned = true
[returned, self.returned]
end
def close!
self.returned = true
[returned, self.returned] # rubocop:disable Style/RedundantSelf
end
end
loan = Loan.new
p loan.close # => [true, false]
p loan.close! # => [true, true]
a = +"Emma"
b = a
b << " (1815)"
p a # => "Emma (1815)"
c = a.dup
c << "!"
p a # => "Emma (1815)"
def shout(text) = text.upcase!
text = +"emma"
p shout(text) # => "EMMA"
p shout(text) # => nil
Source: Assignment, String#upcase!
close sets a local and leaves the attribute false; close! assigns through self. RuboCop’s Style/RedundantSelf knows
that self.returned as a reader is redundant, but it does not report the setter. b = a makes both names refer to one string,
so b << ... changes a; dup makes an independent copy (a shallow one). upcase! returns nil when the receiver was
already upper case, so x = text.upcase! or a chain such as text.upcase!.strip fails with NoMethodError ("undefined
method 'strip' for nil"). Use the non-bang method when you want the value.
What RuboCop catches
RuboCop reports many of these mistakes statically. Running the examples above with every rubocop:disable comment removed
gave this result with RuboCop 1.92 and the shelf configuration.
| Mistake | Cop | Caught? |
|---|---|---|
|
|
Yes. |
Mutable constant used as a default |
|
Yes (the constant), not the mutation. |
|
|
Yes. |
|
|
Yes. |
|
|
No (flags only conditions by default). |
|
|
Yes. |
|
|
Yes. |
|
|
Yes. |
Reassigned constant |
|
Yes. |
|
|
Yes. |
|
none |
No; needs reasoning. |
Float equality |
|
Yes. |
|
none |
No. |
Unsafe |
none |
No. |
Static checks cannot know that a value may be false, or that two threads will call the same method; those need tests and
review. The cops and their configuration are described in
Style, Linting and Static Analysis.
Where the books are outdated
Two of the books consulted for this reference are useful for the principles of these gotchas, and both predate several changes that affect the examples above; they are cited in the bibliography and nothing from them is reproduced here.
-
They show error messages in the pre-3.3 format (a backtick before the method name, and the receiver’s class written as
NilClass). Ruby 3.3 names the receiver (undefined method 'x' for nil) and Ruby 3.4 uses a plain apostrophe instead of the backtick; the messages on this page use the current form. -
They print hashes as
{:name=>"Ico"}; since Ruby 3.4Hash#inspectprints{name: "Ico"}, and so do all the outputs above. -
They state that string literals are frozen by default from Ruby 3.4. They are chilled instead (see the chilled-string section), and the magic comment or
--enable-frozen-string-literalis still required in Ruby 4.0. -
They write
@instance ||= newsingletons; that pattern is not thread-safe, and theSingletonmodule or aMutexis the safe form. -
They use
DateTimeandTime.nowsubtraction for timing;DateTimeis deprecated and a monotonic clock is the right tool. -
They list
BenchmarkandLoggeras standard library; both are bundled gems in Ruby 4.0 and need a Gemfile entry under Bundler.
Version notes
| Version | Change |
|---|---|
3.3 |
|
3.4 |
Chilled string literals warn on first mutation when deprecation warnings are on. |
4.0 |
String literals are still not frozen by default. |
4.1 (upcoming) |
Check the release notes after 2026-12-25. |
References
-
Exceptions, Exception, StandardError and NotImplementedError
-
Kernel#puts, Kernel#p and Float
-
Release notes: Ruby 4.0