Control Expressions

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). Ruby has no separate "statement" world for control flow: every control expression described here returns a value, so if, unless, case, while and begin can be assigned, returned and passed around like any other expression. This page covers the whole family: how Ruby decides whether a condition is true, if/elsif/else/unless and their modifier forms, the ternary operator, combining conditions, case/when and the === operator behind it, the loops (while, until, for, loop) and why the iterator methods are usually preferred, break/next/redo and the values they carry, catch/throw for expected non-local exits, the flip-flop, and BEGIN/END. Features newer than Ruby 3.0 name their version.

It builds on Operators and Precedence (the precedence of &&, ||, and, or, not and the ternary operator, <=>, ===) and Assignment and Multiple Assignment (assignment in a condition). Pages that continue from here are Pattern Matching (case/in), Blocks, Procs and Lambdas (what return, next and break do in a block, a proc and a lambda), Exceptions and Error Handling (rescue, ensure, retry), Enumerable and Enumerators and Lazy Evaluation (the alternatives to explicit loops). For how other languages express the same ideas, see the control-flow pages of Rust and Go in this section of the site, noting the main difference: in Ruby the branching constructs are expressions, there is no switch fall-through and no continue (it is next).

Every example was checked with ruby -wc, run with Ruby 4.0.7 with warnings enabled and linted with RuboCop 1.92 using the same configuration as the running shelf gem (double-quoted strings, TargetRubyVersion: 4.0, new cops enabled). The output shown is the real output. A few examples keep a deliberate # rubocop:disable comment on the construct they illustrate (for, BEGIN/END, the flip-flop, begin ... end while, eval), because the point of the example is that construct. The examples that use the library assume the shelf gem is on the load path (require "shelf"; see the shelf gem source).

Truthiness: what a condition accepts

The documentation of the control expressions states the rule once, and everything else depends on it: nil and false are the only false-values; true and every other object are true-values. There is no implicit conversion and no "falsy zero". 0, 0.0, "", [] and {} are all truthy.

# frozen_string_literal: true

[nil, false, true, 0, "", [], {}, 0.0].each do |value|
  puts "#{value.inspect.ljust(5)} is #{value ? 'truthy' : 'falsy'}"
end
nil   is falsy
false is falsy
true  is truthy
0     is truthy
""    is truthy
[]    is truthy
{}    is truthy
0.0   is truthy

Three consequences are worth knowing:

  • a condition is never required to be a boolean, so methods such as Array#find, Hash#[], String#=~ and Regexp#match can be used directly as conditions: they return an object (truthy) or nil (falsy);

  • a predicate method (name ending in ?) conventionally returns true or false, but the language does not enforce it; many core predicates return truthy values or nil, for example Integer#nonzero? returns the receiver or nil. Write code that depends only on truthiness, not on getting exactly true;

  • !x turns any object into a boolean (the negation), and !!x into its truthiness as a boolean. The double negation is occasionally useful at an API boundary (RuboCop’s Style/DoubleNegation flags it elsewhere), but it is not needed in a condition. The operators themselves are covered in Operators and Precedence.

nil can also be tested with nil?, with &. (safe navigation, which short-circuits a chain on a nil receiver) or by pattern matching. Which of them reads best depends on what the code does when the value is missing.

if, elsif and else

The simplest form has a test expression and a body; then is optional and omitted by almost every program, except to keep a short body on the same line (if x then y end, and then after every when). elsif adds any number of further tests, and else is the branch taken when none matched. Once a branch is taken the rest are not evaluated.

# frozen_string_literal: true

require "shelf"

def shelf_status(copies)
  if copies.zero?
    "out of stock"
  elsif copies < 3
    "running low"
  else
    "plenty"
  end
end

puts shelf_status(0)
puts shelf_status(2)
puts shelf_status(10)

# if is an expression: its value is the last value evaluated
lendable = Shelf::Loan::STATES.include?(:lent)
label = lendable ? "lendable" : "static"
puts label

# a branch that is not taken and has no else yields nil
copies = 0
nothing = ("in stock" if copies.positive?)
p nothing

# unless has no elsif; keep an else for the rare case it reads better
states = Shelf::Loan::STATES
if states.empty?
  puts "no states"
else
  puts "#{states.size} states known"
end
out of stock
running low
plenty
lendable
nil
4 states known

The rules the example shows, and the guide states:

  • the value of an if is the last value evaluated in the branch taken. When no branch is taken (a bare if with a false condition, no else), the value is nil;

  • because if is an expression, you can assign it, return it or pass it as an argument. Prefer the assignment-from-if form to assigning the same variable in every branch;

  • the keyword is elsif: not elseif, else if (which opens a nested if that needs its own end) or elif;

  • unless is the negation of if. It accepts else but not elsif: writing elsif after unless is a syntax error;

  • the body of a modifier or then form can be a single expression; for anything longer, use the multi-line form.

The condition may assign

The tests may have side effects, and the idiomatic use is to cache a value in a local variable and test it in one step. Wrap the assignment in parentheses to show it is deliberate.

# frozen_string_literal: true

require "shelf"

catalog = Shelf::Catalog.new
catalog.add(Shelf::Book.new(isbn: Shelf::Isbn.new(digits: "978-0-13-468599-1"), title: "The Pragmatic Programmer",
                            author: Shelf::Author.new(name: "Thomas")))

# the parentheses document that the assignment is intended
if (book = catalog.find { it.title.start_with?("The") })
  puts "found #{book}"
end
found The Pragmatic Programmer by Thomas

The variable is visible after the if too: a block of if, unless, case, while and for does not create a new local-variable scope (only def, class, module and blocks do; see Variables, Constants and Scope).

A mistyped = for == is the classic bug here. Ruby warns about the clearest cases when warnings are enabled with ruby -w (or $VERBOSE = true): assigning a literal in a condition, and using a literal as a condition at all. These are the real messages from running the checks with Ruby 4.0.7:

w2.rb:4: warning: found '= literal' in conditional, should be ==
w2.rb:7: warning: found '= literal' in conditional, should be ==
w2.rb:10: warning: string literal in condition

Source: Warning

The warning appears for if copies = 1, if copies = "text" and puts "ok" if "always". It does not appear for if (match = regexp.match(text)), where the right-hand side is not a literal. RuboCop’s Lint/LiteralAsCondition and Lint/LiteralAssignmentInCondition check the same things statically.

Conditions can also be patterns

Since Ruby 3.1 (experimental in 3.0), the one-line in and => operators are expressions that can stand in a condition: value in pattern returns true or false, and value => pattern raises NoMatchingPatternError when the pattern does not match. The case/in form is the full pattern-matching construct and has its own page, Pattern Matching; the examples below show how the three forms relate to ordinary conditions. Variables bound by a pattern stay in scope after the construct, as with an assigning if.

# frozen_string_literal: true

config = { env: "production", debug: false, workers: 4 }

if config in { env: "production", debug: false }
  puts "locked down"
end

if config in { workers: Integer => n }
  puts "#{n} workers" if n > 2
end

config => { env: }
puts env

case config
in { env: "development" } then puts "dev"
in { env: String => name, workers: } if workers > 3 then puts "#{name} with many workers"
end

number = 5
begin
  case number
  in String then 1
  end
rescue NoMatchingPatternError => e
  puts "#{e.class}: #{e.message}"
end
locked down
4 workers
production
production with many workers
NoMatchingPatternError: 5: String === 5 does not return true

The failure of the last case shows the one difference from case/when: a case/in without a matching branch and without else raises NoMatchingPatternError, while case/when returns nil.

unless

unless runs its body when the condition is false. It exists to make a negative condition read like English; the style guides agree that it is the right keyword for a short, single negative condition and the wrong one as soon as the condition becomes compound or has an else.

  • unless x is if !x (or if not x), nothing more;

  • never use elsif with it (syntax error), and avoid else: RuboCop’s Style/UnlessElse suggests swapping the branches and using if;

  • avoid unless a && !b and similar: a reader has to negate the whole condition in their head. Name the condition with a variable or a predicate method instead (see Combining conditions);

  • unless is excellent as a modifier in a guard clause (return unless valid?, raise ArgumentError, "..." unless ok).

The error you get for elsif after unless is a syntax error reported when the file is parsed, so nothing in the file runs:

w7.rb:5: warning: possibly useless use of false in void context
w7.rb: --> w7.rb

unexpected 'elsif', ignoring it

> 3  unless true
> 5  elsif false
> 7  end

w7.rb:5: syntax error found (SyntaxError)
  3 | unless true
  4 |   puts 1
> 5 | elsif false
    | ^~~~~ unexpected 'elsif', ignoring it
  6 |   puts 2
  7 | end

Since Ruby 3.4 the default parser is Prism and the diagnostics have this multi-line, caret-annotated form; the same mistakes were a single syntax error, unexpected ... line in Ruby 3.3 and earlier.

Modifier if and unless, and guard clauses

Both keywords can follow an expression: body if test and body unless test. The left side is the "then" statement, the right side the test, and the test is evaluated first.

# frozen_string_literal: true

def checkout(isbn)
  return "no ISBN" if isbn.nil?
  return "blank ISBN" if isbn.empty?

  "checking out #{isbn}"
end

puts checkout(nil)
puts checkout("")
puts checkout("9780134092669")

counter = 0
counter += 1 while counter < 10
p counter

# begin ... end while runs the body once before testing
runs = 0
begin
  runs += 1
end while runs.negative? # rubocop:disable Lint/Loop
p runs
no ISBN
blank ISBN
checking out 9780134092669
10
1

The first method is a guard clause: validate and return early at the top of a method, so that the main path is not nested inside a pyramid of if/else. Good guard clauses are short, use the modifier form, and report errors with the most specific exception class. Raising on invalid arguments is the usual shape for library code:

# frozen_string_literal: true

require "shelf"

def lend(book, days)
  raise ArgumentError, "book required" if book.nil?
  raise ArgumentError, "days must be positive: #{days}" unless days.positive?

  Shelf::Loan.new(book, state: :lent)
end

book = Shelf::Book.new(isbn: Shelf::Isbn.new(digits: "978-0-13-468599-1"), title: "Pragmatic",
                       author: Shelf::Author.new(name: "Thomas"))
puts lend(book, 14).describe
begin
  lend(book, 0)
rescue ArgumentError => e
  puts "#{e.class}: #{e.message}"
end
Pragmatic is lent
ArgumentError: days must be positive: 0

A few cautions about modifiers:

  • a long condition at the end of a line hides the branch; keep modifier lines under the usual length and move compound tests into a named variable or predicate method;

  • do not chain modifiers (x if a unless b): they parse as (x if a) unless b, which no one reads correctly;

  • the modifier form is a statement, not an expression (see below), which matters when you pass it as an argument.

The parse-order trap

A modifier and the standard form are not exact transformations of each other. Ruby parses the file from left to right to decide which names are local variables, but runs the test of a modifier first. So a variable assigned in the test is not yet known as a variable in the body to its left:

# frozen_string_literal: true

source = "p value if value = 0.zero?"
begin
  eval(source, TOPLEVEL_BINDING, "modifier.rb", 1) # rubocop:disable Security/Eval
rescue NameError => e
  puts "#{e.class}: #{e.message}"
end
NameError: undefined local variable or method 'value' for main

When the parser reaches p value, value has not yet been assigned in the source, so it is compiled as a method call. The assignment on the right is then executed first, but it does not change the earlier decision. The eval is only there to turn the file’s NameError into something the example can show; in a normal file, the same line fails the same way. The message format is the Ruby 3.4 one ('value' with straight quotes, no backtick, and for main where Ruby 3.2 and earlier wrote for main:Object). The cure is to assign before the use, or to use the normal if/end form.

The ternary operator

condition ? a : b is an if/else that fits in an expression. Use it when both branches are short and simple; use a full if or a case when they are not. The documentation recommends it only for simple conditionals and warns against several ternaries in one expression.

# frozen_string_literal: true

def plural(count, word) = "#{count} #{count == 1 ? word : "#{word}s"}"

puts plural(1, "book")
puts plural(3, "book")

# prefer case over a nested ternary
n = 15
size =
  case n
  when ...10 then "small"
  when 10...100 then "medium"
  else "large"
  end
puts size
1 book
3 books
medium

Points to remember:

  • write whitespace around both ? and :, and give predicate names their ? without a space (ready? ? a : b); the symbol and character-literal syntaxes (:a, ?a) make cramped ternaries ambiguous;

  • the ternary has lower precedence than ||, && and .., and higher than assignment and not, so a = b ? c : d assigns the whole ternary, and puts x ? "a" : "b" needs no parentheses;

  • never nest ternaries. RuboCop’s Style/NestedTernaryOperator flags it. The example shows the case that replaces them, with beginless and endless ranges (Ruby 2.7 and 2.6 respectively) in when;

  • a multi-line ternary is allowed (the ? or : can end a line) but a plain if is clearer.

Combining conditions

Boolean logic has two families of operators, and they differ in precedence and nothing else.

  • &&, || and ! bind tightly and are the ones to use inside conditions;

  • and, or and not bind looser than assignment (and and/or have equal precedence, left to right), so they work as flow control, in the Perl style: command or raise "failed".

# frozen_string_literal: true

def lookup(title) = title == "Dune" ? "found" : nil

# && and || bind tighter than =; 'and' and 'or' bind looser than =
yes = true
no = false
a = no || yes
b = (no or yes)
c = no or yes
p [a, b, c]

# 'or' as flow control: the assignment happens first
result = lookup("Unknown") or puts("not in the catalogue")
p result

lookup("Dune") and puts("Dune is in")

# ! and not differ only in precedence
value = nil
# rubocop:disable Style/Not, Style/RedundantParentheses
p(!value)
p((not value))
# rubocop:enable Style/Not, Style/RedundantParentheses
[true, true, false]
not in the catalogue
nil
Dune is in
true
true

The third value in the first line is the trap: c = no or yes assigns no to c and then evaluates yes, discarding it. That is the reason many style guides avoid and/or altogether. RuboCop’s Style/AndOr (default style conditionals) allows them only as flow control outside if/while conditions. The result = lookup("Unknown") or puts(...) line shows the legitimate use: the assignment completes first, and the right operand runs only when the value is falsy.

Both families short-circuit: the right operand is evaluated only when needed, and the whole expression returns the operand that decided it, not a boolean. nil || "default" returns "default", and user && user.name returns nil when user is nil. That is why || is the idiom for defaults and && for guarded access (though &. is cleaner for the latter). not is a keyword that is equivalent to ! except for its precedence; ! is a method that can be redefined, not calls the same method.

Naming the conditions

When a condition has more than a couple of terms, extract each into a local variable (an explaining variable) or a predicate method (a query method). The condition then reads as the rule it implements. Local variables are best when the terms are used once; predicate methods when they are reused or testable on their own.

# frozen_string_literal: true

require "shelf"

Reader = Data.define(:name, :age, :banned)

def may_borrow?(reader, copies)
  adult = reader.age >= 18
  in_good_standing = !reader.banned
  stock_left = copies.positive?

  adult && in_good_standing && stock_left
end

p may_borrow?(Reader.new("Ana", 30, false), 2)
p may_borrow?(Reader.new("Bo", 12, false), 2)

# Ruby 4.0: a line may start with && or || (or and / or) and continues the previous line
def may_borrow_v2?(reader, copies)
  reader.age >= 18
    && !reader.banned
    && copies.positive?
end
p may_borrow_v2?(Reader.new("Ana", 30, false), 0)
true
false
false

The second method uses a feature new in Ruby 4.0: a line that starts with &&, ||, and or or continues the previous line, like a leading . does for a method chain. Before 4.0 the same code is a syntax error, so write the operator at the end of the line if you must support Ruby 3.4 and earlier. The usual layout rules apply: align the operators under the first operand, and keep the operators consistent (all leading or all trailing).

case and when

case compares one value against several candidates and runs the first branch that matches. It has two forms.

case with a subject

For each when, Ruby evaluates candidate === subject: the candidate is the receiver and the subject the argument. This is the case equality operator ===. Object#=== is aliased to ==, and the classes where "matching" means something more specific override it:

Candidate in when === tests whether the subject …​ Defined in

a class or module (Integer, Comparable)

is an instance of it, including subclasses and included modules

Module#===

a range (1..7, ..0, 31..)

falls inside it (cover?-style comparison, so any <=>-comparable value works)

Range#===

a regexp (/\A\d\z/+)

is a string or symbol that matches; sets $~ and $1 …​ on success

Regexp#===

a proc or lambda

makes the callable return a truthy value when called with the subject

Proc#===

a Set

is a member (core class in Ruby 4.0)

Set#===

a symbol, string, number, nil, true, false

is equal (==)

Object#=== and the overrides in String, Symbol, Integer

# frozen_string_literal: true

def classify(value)
  case value
  when nil then "nothing"
  when Integer, Float then "number"
  when /\A\d{13}\z/ then "bare ISBN-13"
  when String, Symbol then "text"
  when Array then "list of #{value.size}"
  when ->(v) { v.respond_to?(:each) } then "something enumerable"
  else "unknown"
  end
end

[nil, 3, 2.5, "9780134685991", "Dune", :dune, [1, 2], 1..3, Object].each do |v|
  puts "#{v.inspect} -> #{classify(v)}"
end
nil -> nothing
3 -> number
2.5 -> number
"9780134685991" -> bare ISBN-13
"Dune" -> text
:dune -> text
[1, 2] -> list of 2
1..3 -> something enumerable
Object -> unknown

Rules the example demonstrates:

  • branches are tried top to bottom, the first match wins, and later branches are not evaluated. Put the specific tests before the general ones: /\A\d{13}\z/ is placed before String above because a String also matches String;

  • several candidates on one when are separated by commas and tried left to right (a comma means "or");

  • then lets the body follow on the same line; without it, the body goes on the next line;

  • a case that has no matching branch and no else returns nil;

  • the value of the whole case is the value of the branch taken, so it can be assigned or returned directly.

The when list can contain ranges, including beginless and endless ones, which makes numeric classification compact, and a splat expands an array (or anything with to_a) into candidates:

# frozen_string_literal: true

def loan_fee(days_late)
  case days_late
  when ..0 then 0
  when 1..7 then 25
  when 8..30 then 100
  when 31.. then 500
  end
end

fees = [-3, 0, 1, 7, 8, 30, 31, 400].map { loan_fee(it) }
p fees

# splat a list into when
OPEN_STATES = %i[available lent].freeze
CLOSED_STATES = %i[overdue returned].freeze

def phase(state)
  case state
  when *OPEN_STATES then :open
  when *CLOSED_STATES then :closed
  end
end

p phase(:lent), phase(:returned), phase(:lost)
[0, 0, 25, 25, 100, 100, 500, 500]
:open
:closed
nil

Source: Range#===

when ..0 and when 31.. are a beginless and an endless range; they are what replaces the open-ended else if ladders written with comparison operators. The splat form when *OPEN_STATES tests each element in turn, so a constant array of allowed values can be shared between a case and a validation.

Showing === at work

Because when is nothing but ===, you can call it yourself and you can define it. A class that defines a class-level === can be used as a when candidate, which is how value objects, custom matchers and small DSLs plug into case.

# frozen_string_literal: true

# rubocop:disable Style/CaseEquality
range = 1..5
squared = ->(n) { n > 2 }
seen = :a
p Integer === 3
p range === 3
p(/ab/ === "cab")
p seen === :a
p squared === 3
p Set[1, 2] === 2
# rubocop:enable Style/CaseEquality

title = "Shelf 2.0"
case title
when /(\d)\.(\d)/
  p [Regexp.last_match(1), Regexp.last_match(2)]
end

# when with a custom ===
class Even
  def self.===(other) = other.respond_to?(:even?) && other.even?
end

number = 4
p(case number when Even then "even" else "odd" end)
true
true
true
true
true
true
["2", "0"]
"even"

Source: Object#===

RuboCop’s Style/CaseEquality discourages writing === explicitly outside when; the example disables it because showing the operator is the point. Note that Regexp#=== sets the match globals just like =~; since $~ and $1 are frame-local and easy to overwrite, prefer Regexp.last_match(1) or named captures, as above (RuboCop’s Style/PerlBackrefs).

case without a subject

When case has no subject, each when holds an arbitrary condition, and the first truthy one wins. It reads like an if/elsif chain with aligned tests, and it is the right choice when every branch tests something different.

# frozen_string_literal: true

def describe(copies, reserved)
  case # rubocop:disable Style/EmptyCaseCondition
  when copies.zero? then "none left"
  when reserved >= copies then "all reserved"
  when copies > 5 then "plenty"
  else "available"
  end
end

puts describe(0, 0)
puts describe(2, 2)
puts describe(9, 0)
puts describe(3, 1)

# a case with no matching branch and no else is nil
number = 1
p(case number when 2 then :two end)

# the value of case can be assigned directly
file = "x.rb"
kind =
  case file
  when /\.rb\z/ then :ruby
  else :other
  end
p kind
none left
all reserved
plenty
available
nil
:ruby

RuboCop’s Style/EmptyCaseCondition prefers an if chain; the style choice is local to a project. In a subject-less case, a comma-separated list is "or" as well.

Duplicates and ordering

With warnings enabled, Ruby reports a when whose literal candidate repeats an earlier one, because that branch can never run:

w1.rb:7: warning: 'when' clause on line 7 duplicates 'when' clause on line 5 and is ignored

Source: Warning

Beyond that, the order of branches is yours to get right: a class candidate such as Numeric in front of Integer hides the Integer branch without any diagnostic. The implementation compiles a case whose when candidates are all literals (integers, strings, symbols, nil, true, false) into a hash dispatch instead of a sequence of === calls, as long as === of those core classes has not been redefined, so a long case over literals costs one hash lookup instead of a chain of comparisons. For other kinds of candidate the comparisons run in order.

case with in

Since Ruby 2.7 (stable in 3.0) case can also use in clauses for pattern matching, which matches structure (arrays, hashes, Data, Struct, find patterns, guards) and binds variables, not just ===. case/when and case/in cannot be mixed in one case. The shelf gem’s Loan#describe is the complete example; the details are in Pattern Matching.

while and until

while repeats its body as long as the condition is true; until repeats it as long as the condition is false. The condition is tested before the first pass (zero iterations are possible), do is optional (and rare), and the value of the loop is nil unless break supplies one.

# frozen_string_literal: true

copies = 3
while copies.positive?
  puts "#{copies} copies left"
  copies -= 1
end

# until is while with the condition negated
attempts = 0
until attempts >= 2
  attempts += 1
  puts "attempt #{attempts}"
end

# the value of a loop is nil unless break supplies one
limit = 5
n = 0
result =
  while n < limit
    n += 1
    break :done if n == 3
  end
p result
p(while n > limit do end)

# 'do' is optional; here it keeps a one-line body readable
queue = [1, 2, 3]
puts queue.shift * 2 while queue.any?
3 copies left
2 copies left
1 copies left
attempt 1
attempt 2
:done
nil
2
4
6

while and until also exist as modifiers, which is the form used for one-line loops: counter += 1 while counter < 10 (the first example in the modifier section). RuboCop’s Style/WhileUntilModifier prefers it for a single-statement body. Style/InfiniteLoop suggests loop do instead of while true.

begin …​ end while

When the loop modifier is attached to a begin ... end block, the body runs once before the condition is tested, like a do ... while loop in C. This is the only case where a modifier tests after the body.

The example under Modifier if and unless shows it: the condition is runs.negative?, false from the start, yet the body ran once. The documentation notes that without a rescue or ensure Ruby optimises away the exception-handling overhead. Rubocop’s Lint/Loop recommends loop do ... break if ... end for this shape, which is more explicit and also works with next. A begin block that has a modifier if or unless is an ordinary conditional that tests first.

for

for iterates by calling each: for x in collection. It differs from each in one important way: it does not create a new variable scope, so the loop variable (and any variable assigned in the body) is still visible after the loop. The value of a for expression is the collection iterated, unless break supplies another value. The guide’s verdict is that it is rarely used in modern Ruby code, and RuboCop’s Style/For flags it.

# frozen_string_literal: true

# rubocop:disable Style/For
titles = %w[Dune Emma]
for title in titles do
  puts title
end
puts "title is still visible: #{title}"

%w[Hamlet Ulysses].each do |name|
  puts name
end
puts defined?(name).inspect

# for returns the thing it iterated
result = (for i in 1..2 do i end)
p result

# for destructures like a block would
for isbn, copies in { "111" => 2, "222" => 5 } do
  puts "#{isbn}: #{copies}"
end
# rubocop:enable Style/For
Dune
Emma
title is still visible: Emma
Hamlet
Ulysses
nil
1..2
111: 2
222: 5

title is still defined after the for, name is undefined after the each (defined?(name) returns nil). This leakage is the common source of bugs: an i from a for can overwrite an outer i, and a closure created in the body captures the one shared variable, not one per pass. The same loop with each gets a fresh binding on every iteration. Use for only where you need exactly its scope behaviour or its brevity in a throwaway script, never in a library.

loop and StopIteration

Kernel#loop repeats its block forever, which makes it the Ruby spelling of an infinite loop. It exits in three ways: break (with an optional value, which loop returns), an exception, or a StopIteration. loop rescues StopIteration itself and returns the exception’s result. That makes it the standard way to drain an external enumerator:

# frozen_string_literal: true

numbers = [1, 2, 3].each
loop do
  puts numbers.next
end

# loop returns the StopIteration result
queue = [10, 20].each
outcome = loop { queue.next }
p outcome

# break gives loop a value
count = 0
found = loop do
  count += 1
  break count * 100 if count == 3
end
p found

# an enumerator's own StopIteration
e = [1].each
e.next
begin
  e.next
rescue StopIteration => e
  puts "#{e.class}: #{e.message}"
  p e.result
end
1
2
3
[10, 20]
300
StopIteration: iteration reached an end
[1]

Source: Kernel#loop

Enumerator#next raises StopIteration ("iteration reached an end") after the last element; the exception’s result is the return value of the underlying iteration method, which is why the second example returns [10, 20] (the result of Array#each). Without loop, you have to rescue StopIteration explicitly, as the last lines show. See Enumerators and Lazy Evaluation for external versus internal iteration. The exception class is documented at StopIteration#result.

loop called without a block returns an enumerator (an infinite one, Enumerator#size is Float::INFINITY), so it can feed lazy pipelines. A raised error that is not a StopIteration propagates normally and terminates the loop.

Iterating over numbers and collections

Counting loops are better expressed as an iterator method than as while with a manual counter. These are the idioms that replace the C-style for (i = 0; i < n; i++):

# frozen_string_literal: true

3.times { print it, " " }
puts
1.upto(3) { print it, " " }
puts
3.downto(1) { print it, " " }
puts
1.step(10, 4) { print it, " " }
puts
10.step(1, -4) { print it, " " }
puts
0.5.step(by: 0.5, to: 2.0) { print it, " " }
puts

%w[a b c].each_with_index { |s, i| print "#{i}:#{s} " }
puts
p((1..3).each_with_object([]) { |i, acc| acc << (i * i) })

# the loop equivalents
total = 0
(1..4).each { total += it }
p total
p((1..4).sum)
p((1..4).reduce(:*))
0 1 2
1 2 3
3 2 1
1 5 9
10 6 2
0.5 1.0 1.5 2.0
0:a 1:b 2:c
[1, 4, 9]
10
10
24

times, upto, downto and step are the counting loops (step accepts a negative step and, for floats, by: and to: keywords); each_with_index and each_with_object carry an index or an accumulator. Inside these blocks, it (Ruby 3.4) or _1 name the single parameter. For algorithms that really are index arithmetic (two pointers, binary search, a sieve), a while loop is still the clearest tool.

The collection methods are chosen by what the loop computes, not by how it iterates:

You want …​ Use

a transformed copy

map (and flat_map when each element yields several)

only some elements

select/filter, reject, take_while/drop_while, filter_map (map and drop nil in one pass)

the first element that satisfies a test

find/detect (returns the element, not an array)

a boolean over the whole collection

any?, all?, none?, include?

one accumulated value

sum, reduce/inject, each_with_object, tally, group_by

a side effect per element

each

Source: Enumerable

A loop that builds an array by each plus <<, or that searches with each and a flag, is almost always a map, select or find waiting to be written. Two rules from the "extract the block body" refactoring keep these readable: when a block exceeds a handful of lines, name it as a method; and use the method that states the intent (find instead of select(...).first). The Enumerable methods are on their own page, Enumerable.

break, next and redo

Three keywords alter a loop or a block from inside. They work identically in while/until/for and in blocks passed to iterators (the exact behaviour inside a proc versus a lambda is on Blocks, Procs and Lambdas).

Keyword Effect Value it can carry

break

leaves the loop, or the method that yielded to the block, immediately

the result of the loop or of the iterator call; nil if omitted

next

ends the current iteration and starts the next one

the result of the current block call (for example, the element map collects)

redo

restarts the current iteration without re-testing the condition or advancing the iterator

none

retry

only legal inside a rescue clause: runs the begin block again

none

# frozen_string_literal: true

copies = [3, 0, 2, 5]

# break leaves the iterator and can supply its value
first_missing = copies.each_with_index do |n, i|
  break i if n.zero?
end
p first_missing

# when the iterator finishes normally, the value is the iterator's own
p(copies.each { |n| break n if n > 100 })

# next skips to the following iteration and can supply the block value
labelled = copies.map do |n|
  next :none if n.zero?

  n * 2
end
p labelled

skipped = copies.map do |n|
  next if n.zero?

  n * 2
end
p skipped

# break out of a while loop
i = 0
stopped =
  while i < 10
    i += 1
    break "stopped at #{i}" if i == 4
  end
p stopped

# next in a while loop re-tests the condition
odds = []
n = 0
while n < 8
  n += 1
  next if n.even?

  odds << n
end
p odds
1
[3, 0, 2, 5]
[6, :none, 4, 10]
[6, nil, 4, 10]
"stopped at 4"
[1, 3, 5, 7]

What the example shows:

  • break value becomes the result of the whole iterator call (each_with_index here) or loop. A break without a value makes the call return nil. If the iterator is not interrupted, its normal result is returned instead: each returns the receiver, which is why the second line prints the whole array;

  • next is Ruby’s continue, and next value supplies the block’s result. A bare next yields nil, so map { next if x.zero?; x * 2 } leaves nil holes in the result; use filter_map or reject when you wanted to skip elements;

  • in a while loop next jumps to the condition check, so a counter updated after next can be skipped; update it before.

redo

redo repeats the current iteration with the same element, without testing the condition. It is rare and error-prone, since it is easy to loop forever, but it exists for retry-style loops that must not advance. The first example restarts an iteration for the first item twice; the second is the example from the language guide.

# frozen_string_literal: true

attempts = Hash.new(0)
%w[a b].each do |item|
  attempts[item] += 1
  redo if item == "a" && attempts[item] < 3
end
p attempts

# the example from the language guide
result = []
while result.length < 10
  result << result.length
  redo if result.last.even?
  result << (result.length + 1)
end
p result
{"a" => 3, "b" => 1}
[0, 1, 3, 3, 5, 5, 7, 7, 9, 9, 11]

redo is not the same as retry. In Ruby 1.8 either could be used for this, but since Ruby 1.9 retry is only valid in a rescue clause, where it re-runs the begin block (see Exceptions and Error Handling); anywhere else it is a syntax error:

w4.rb: --> w4.rb

Invalid retry without rescue

> 3  retry

w4.rb:3: syntax error found (SyntaxError)
  1 | # frozen_string_literal: true
  2 |
> 3 | retry
    | ^~~~~ Invalid retry without rescue

Interaction with ensure

break, next, redo and return all run pending ensure clauses on the way out. Since Ruby 2.5 a do ... end block can have its own rescue/ensure, which is the neat way to attach cleanup to each iteration:

# frozen_string_literal: true

def scan(items)
  items.each do |item|
    break item if item > 1
  ensure
    puts "checked #{item}"
  end
end
p scan([1, 2, 3])
checked 1
checked 2
2

Source: Exceptions

The ensure of the iteration that executed break still printed ("checked 2"), and the value of the break was returned.

break, next and return in blocks, procs and lambdas

Inside a block, break leaves the method that received the block, next leaves the block only, and return returns from the method that defined the block. A proc stored and called after its defining method has finished cannot break out of anything, and that situation raises LocalJumpError. A lambda treats return and break like next (they leave the lambda only).

# frozen_string_literal: true

pr = proc { break 1 }
begin
  pr.call
rescue LocalJumpError => e
  puts "#{e.class}: #{e.message}"
  p e.reason
end

def helper = yield

p(helper { next 5 })

lam = -> { break 7 }
p lam.call

def first_even(numbers)
  numbers.each { |n| return n if n.even? }
  nil
end
p first_even([1, 4, 5])
LocalJumpError: break from proc-closure
:break
5
7
4

Source: LocalJumpError

LocalJumpError#reason is :break here; the class page lists the other values. The full account of return/next/break in blocks, procs and lambdas, with a diagram, is on Blocks, Procs and Lambdas.

Leaving nested loops

Ruby has no labelled break. break leaves only the innermost loop, so leaving two levels at once needs another mechanism. In order of preference:

  1. extract the search into a method and return from it (it also gives the search a name);

  2. use catch/throw when the exit is an expected outcome of the algorithm and extracting a method is awkward;

  3. let an Enumerable method say it (find, any?, flat_map ... first), because the iterator stops by itself;

  4. only if nothing else fits, set a flag and test it in each loop.

# frozen_string_literal: true

GRID = [[1, 2, 3], [4, 5, 6], [7, 8, 9]].freeze

# 1. extract a method and return from it
def find_cell(grid, target)
  grid.each_with_index do |row, y|
    row.each_with_index do |value, x|
      return [x, y] if value == target
    end
  end
  nil
end
p find_cell(GRID, 6)

# 2. catch/throw for an expected early exit from deep inside
position = catch(:found) do
  GRID.each_with_index do |row, y|
    row.each_with_index { |value, x| throw :found, [x, y] if value == 8 }
  end
  nil
end
p position

# 3. often an Enumerable method says it directly
first_big = GRID.flatten.find { it > 4 }
p first_big
[2, 1]
[1, 2]
5

Source: Kernel#catch

catch and throw

catch and throw are the non-local jump for control flow that is expected, as opposed to exceptions, which are for failures. catch(tag) { ... } runs the block; a throw tag, value anywhere inside it, however deeply nested through method calls, unwinds the stack up to the matching catch and makes that catch return value (nil if omitted). If no throw happens, catch returns the block’s value. The tag is compared with identity (equal?), so symbols and objects work; strings do not match across different objects. Without an argument, catch creates a fresh Object and passes it to the block, which makes the tag unforgeable.

# frozen_string_literal: true

require "shelf"

SHELVES = [[1, 2], [3, 4], [5, 6]].freeze

found = catch(:found) do
  SHELVES.each do |shelf|
    shelf.each do |n|
      throw :found, n if n * n > 10
    end
  end
  :nothing
end
p found

# without a matching throw, catch returns the block value
p(catch(:x) { :normal })

# the tag can be any object; a fresh Object is private to the block
r = catch do |tag|
  [1, 2, 3].each { |n| throw tag, n * 10 if n == 2 }
  :unreached
end
p r

# a throw to a tag nobody catches is an error
begin
  throw :nobody
rescue UncaughtThrowError => e
  puts "#{e.class}: #{e.message}"
  p e.tag, e.value
end
4
:normal
20
UncaughtThrowError: uncaught throw :nobody
:nobody
nil

A throw whose tag no active catch matches raises UncaughtThrowError (a subclass of ArgumentError), with the tag and value readable from the exception. That is deliberate: it tells you that the catch is missing rather than silently doing nothing. ensure clauses between the throw and the catch run as the stack unwinds, exactly as for exceptions.

catch and throw are ordinary methods of Kernel, not keywords. Inside a BasicObject subclass, which does not include Kernel, call ::Kernel.catch and ::Kernel.throw. They are used by gems whose parsers or tree walkers stop early (for example, Ruby’s own Find.prune, and Sinatra’s halt), but in application code a return from an extracted method usually reads better. See Kernel#throw and UncaughtThrowError.

Statements and expressions

Ruby’s grammar distinguishes statements from expressions. Every expression is a statement, but not every statement is an expression. In their standard forms, if, unless, while, until and begin are expressions. The modifier forms of if, unless, while, until and rescue are statements that are not expressions. Contexts that need an expression, such as a method argument, therefore reject a modifier form unless you add parentheses:

w3.rb:3: syntax errors found (SyntaxError)
  1 | # frozen_string_literal: true
  2 |
> 3 | puts(1 if true)
    |        ^~ unexpected 'if'; expected a `)` to close the arguments
    |               ^ unexpected ')', ignoring it
    |               ^ unexpected ')', expecting end-of-input
# frozen_string_literal: true

ready = true
# a modifier if is a statement, not an expression: wrap it in parentheses
puts((1 if ready))
value = (2 unless ready)
p value

# a plain if ... end is an expression and needs no extra parentheses
puts(if ready then 3 end)
1
nil
3

puts (1 if ready) with a space is also accepted: it is read as a call without parentheses whose argument is a parenthesised statement. The double parentheses are the clearer spelling. The same split decides how a modifier rescue groups: a if b rescue c is (a if b) rescue c, because b rescue c is a statement and cannot be the test of an if modifier. Modifier rescue has higher precedence than =, and modifier if lower, so stmt if v = expr rescue x is stmt if v = (expr rescue x).

The flip-flop

A range literal used as a condition (inside if, unless, while, until, ?:, !, not, and the modifier forms) is not a Range at all but a flip-flop: a hidden boolean that turns on when its left condition becomes true, stays on while the right condition is false, and turns off when it is true. It was designed for one-liners that select a block of lines (ruby -ne 'print if /BEGIN/../END/').

# frozen_string_literal: true

# rubocop:disable Lint/FlipFlop
lines = %w[intro BEGIN one two END outro]
inside = lines.select { |l| true if (l == "BEGIN")..(l == "END") }
p inside

# three dots: the end condition is not tested on the line that turned it on
p((0..5).select { |v| true if (v == 2)...(v == 2) })
p((0..5).select { |v| true if (v == 2)..(v == 2) })
# rubocop:enable Lint/FlipFlop
["BEGIN", "one", "two", "END"]
[2, 3, 4, 5]
[2]

With two dots (..) the off condition is tested on the same pass in which the flip-flop turned on, so (v == 2)..(v == 2) selects just [2]. With three dots the off condition is only tested starting on the next pass, so (v == 2)...(v == 2) never turns off and selects [2, 3, 4, 5]. The state is hidden in the method frame (it is a hidden local variable), which makes the flip-flop a source of surprises in methods called more than once and in code that uses threads. RuboCop’s Lint/FlipFlop flags it. Use a plain boolean variable (inside = true if ...) for readable code; the precedence of the range operators is on Operators and Precedence.

BEGIN, END and at_exit

BEGIN { ... } runs its block before anything else in the file, and END { ... } runs its block when the program exits. Both require braces (do ... end is a syntax error), BEGIN must be at the top level, and an END block registers itself once, the first time the statement is executed.

# frozen_string_literal: true

# rubocop:disable Style/EndBlock, Style/BeginBlock
puts "main body"
END { puts "END block runs last" }
at_exit { puts "at_exit handler" }
END { puts "second END (runs first: reverse order)" }
BEGIN { puts "BEGIN block runs first" }
# rubocop:enable Style/EndBlock, Style/BeginBlock
BEGIN block runs first
main body
second END (runs first: reverse order)
at_exit handler
END block runs last

END blocks and Kernel#at_exit handlers share one list that runs in reverse order of registration, as the output shows (the second END, registered last, ran first). END inside a method warns, since each call would register another handler:

w6.rb:4: warning: END in method; use at_exit

Source: Kernel#at_exit

In practice, BEGIN and END are for ruby -n/-p one-liners (for example, ruby -ne 'BEGIN { count = 0 }; END { puts count }; count += gets.to_i' sums numbers from standard input). In a program, use at_exit (RuboCop’s Style/EndBlock and Style/BeginBlock say the same). exit and abort raise SystemExit so that ensure clauses and exit handlers still run; see Ruby Command Line and Environment for the -n and -p switches and Exceptions and Error Handling for SystemExit.

Diagnostics at a glance

You see Cause Fix

NameError: undefined local variable or method 'x' for main on a modifier line

a variable assigned in the test of a modifier if is used in its body

assign before, or use if ... end

warning: found '= literal' in conditional, should be ==

= instead of == in a condition

use ==, or parenthesise a deliberate assignment of a non-literal

warning: string literal in condition

a literal where a test was meant

remove it or test a real expression

warning: 'when' clause on line N duplicates 'when' clause on line M and is ignored

the same literal twice in a case

merge or delete the duplicate

SyntaxError ... unexpected 'elsif' after unless

unless has no elsif

restructure with if

SyntaxError ... Invalid retry without rescue

retry outside a rescue clause

use redo, a loop, or move retry into rescue

LocalJumpError: break from proc-closure

a proc calls break after its defining method has returned

use next, or a lambda

UncaughtThrowError: uncaught throw :tag

throw with no matching catch in progress

wrap the call in catch(:tag) or fix the tag

NoMatchingPatternError

case/in or => with no matching pattern and no else

add a branch or else, or use in as a boolean test

warning: END in method; use at_exit

END inside a method body

use at_exit

Source: Warning

Style and pitfalls

  • Prefer expressions over assignments in every branch. label = if cond then a else b end beats assigning label in both arms.

  • Prefer guard clauses over nesting, and iterators over index loops.

  • Do not use and/or in conditions: precedence differs from &&/|| and =; use them, if at all, only as flow control.

  • Do not test with == true or == false; test the value (truthiness) or use the predicate.

  • Do not depend on nil as a "not found" marker if nil can be a valid value; use fetch with a default or a KeyError (see Hashes).

  • Do not modify a collection while iterating over it with each/while; iterate over a copy (dup) or build a new one.

  • A while loop does not create a scope, a block does. A variable first assigned inside a while is visible after it; one first assigned inside a block is not.

  • loop hides an exit condition in the body, so state it clearly at the top (break if done?), or use until when the exit is the loop’s purpose.

  • case over a class hierarchy is a smell when it grows: it is an if-ladder on type that polymorphism (a method on each class) replaces; see SOLID and Design Patterns.

Version notes

Version Change relevant to this page

Ruby 2.6

endless ranges (1..) in 2.6; do ... end blocks accept rescue/ensure since 2.5

Ruby 2.7

beginless ranges (..5), case/in pattern matching (experimental)

Ruby 3.0

pattern matching stable for case/in; one-line in and => forms added (experimental); endless methods

Ruby 3.1

one-line in and => stable; pin expressions in patterns; hash shorthand {x:}

Ruby 3.2

find pattern stable

Ruby 3.4

the it block parameter; Prism parser with multi-line syntax errors; error messages with 'name' quotes and no receiver class suffix

Ruby 4.0

a line starting with &&, `

, `and or or continues the previous line; *nil no longer calls nil.to_a

Where older books and articles are outdated

Material written for Ruby 2.x or early 3.x remains right about control flow, which has hardly changed, but a few details are dated:

  • examples of error output with undefined method x' for nil:NilClass and an unquoted backtick: since Ruby 3.4 the text is `undefined method 'x' for nil, with straight quotes and the receiver described as nil or an instance of Foo;

  • _1-only loop and block examples: Ruby 3.4 added it, shown in the examples above, which reads better for a single parameter (_1 is still valid, but not combinable with it);

  • hashes printed as {:name=>"Ico"}: since Ruby 3.4, inspect prints {name: "Ico"} and {"a" => 3}, with spaces around =>;

  • guidance that predicate methods must return true or false, and that ! always means "mutates": neither is a rule of the language (see the truthiness section);

  • pattern matching presented as new in 2.7: it is stable since 3.0, with the find pattern stable in 3.2;

These points are only corrections; the rest of the catalogue of constructs in this page is unchanged since Ruby 1.9.

References