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

Array.new(3, []), Hash.new([])

Every slot, or every missing key, shares one object.

Array.new(3) { [] }, Hash.new with a block that assigns

Deleting from an array inside its own each

Elements are skipped, with no error.

reject, reject!, delete_if, or iterate over a copy.

Adding a key to a hash inside its own each

RuntimeError at the first new key.

Build a new hash, or collect the changes and apply them afterwards.

x = a and b

Parsed as (x = a) and b, so x ignores b.

Use && and || for values; and/or only for control flow.

if copies = 0

Assigns, then tests the always-truthy 0.

==, or a predicate such as zero?.

rescue Exception

Swallows Ctrl-C, exit and syntax errors of loaded files.

rescue StandardError, or a specific class.

rescue ParseError for an unrelated error class

The exception sails past the handler.

Rescue the class that is really raised, or a common parent.

raise NotImplementedError in an abstract method

A bare rescue or rescue StandardError does not catch it.

Name it explicitly, or raise NoMethodError.

super where super() was meant (and the reverse)

Arguments are passed, or dropped, silently.

Write the arguments you mean, or super() for none.

A hook such as inherited or included without super

The parent’s hook never runs.

Call super first.

LIMIT = 5 a second time

A warning, and the new value wins.

Do not reassign; freeze the object and use a new name.

x ||= default where x may be false

false is replaced by the default.

fetch, key? or nil?.

puts instead of p while debugging

nil, empty arrays and strings look alike.

p or pp, which print inspect.

0.1 + 0.2 for money

Binary floating point error.

Integer cents, BigDecimal or Rational.

Time.now - start for durations

The wall clock can jump.

Process.clock_gettime(Process::CLOCK_MONOTONIC).

Mutating a string literal

A chilled-string warning (3.4+); an error with the magic comment.

+"text", String.new, or dup.

case/in with no else

NoMatchingPatternError, unlike case/when, which returns nil.

Add else, or rescue the error on purpose.

@instance ||= new

Two threads can build two instances.

Singleton, or a Mutex.

returned = true in a method

Assigns a local variable, not the attribute.

self.returned = true.

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"]

Source: Array.new, Hash.new

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}

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

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"

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

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

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

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

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}

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"

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

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

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

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

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

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?

Hash.new([])

Lint/SharedMutableDefault

Yes. Array.new(3, []) is not reported.

Mutable constant used as a default

Style/MutableConstant

Yes (the constant), not the mutation.

p x.y { ... } block binds to p

Lint/AmbiguousBlockAssociation

Yes.

not x == y

Style/Not, Style/InverseMethods

Yes.

ok = a and b outside a condition

Style/AndOr

No (flags only conditions by default).

if x = 0

Lint/AssignmentInCondition, Lint/LiteralAssignmentInCondition

Yes.

rescue Exception

Lint/RescueException

Yes.

rescue A, B with one a subclass of the other

Lint/ShadowedException

Yes.

Reassigned constant

Lint/ConstantReassignment

Yes.

inherited or initialize without super

Lint/MissingSuper

Yes.

a ||= b with a possibly false value

none

No; needs reasoning.

Float equality

Lint/FloatComparison

Yes.

Time.now for durations

none

No.

Unsafe @instance ||= new

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.4 Hash#inspect prints {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-literal is still required in Ruby 4.0.

  • They write @instance ||= new singletons; that pattern is not thread-safe, and the Singleton module or a Mutex is the safe form.

  • They use DateTime and Time.now subtraction for timing; DateTime is deprecated and a monotonic clock is the right tool.

  • They list Benchmark and Logger as standard library; both are bundled gems in Ruby 4.0 and need a Gemfile entry under Bundler.

Version notes

Version Change

3.3

NoMethodError messages name the receiver (for an instance of ClassName, for nil).

3.4

Chilled string literals warn on first mutation when deprecation warnings are on. Hash#inspect prints {name: "Ico"}. Error messages quote with ' instead of a backtick.

4.0

String literals are still not frozen by default. Benchmark, Logger, BigDecimal and other libraries are bundled gems. Set is a core class.

4.1 (upcoming)

Check the release notes after 2026-12-25.