case Expressions

Published by Programming Line in Ruby. Last updated .

Ruby’s case expression is its answer to a multi-branch conditional, but it is far more powerful than a C-style switch statement. In its classic form, case/when, it tests a value against several candidates using Ruby’s case equality operator ===, so it can match ranges, classes, and regular expressions just as easily as literal values. Since Ruby 2.7, a second form, case/in, adds full pattern matching, letting you destructure arrays and hashes and bind their pieces to variables in one expression. Like almost everything in Ruby, case is itself an expression — it evaluates to a value you can assign, return, or interpolate directly.

Overview: How case Works

A case/when block evaluates its subject once, then walks through each when clause from top to bottom. The key thing to understand is which side calls which: Ruby does not compare with subject == candidate. It calls candidate === subject, using the case equality operator on whatever follows when. Most classes inherit the default Object#===, which behaves just like ==, but several core classes override it to do something much more useful:

When target What === does Example
Range Checks whether the value falls inside the range (1..9) === 5 is true
Class / Module Checks whether the value is_a? that class or includes that module Integer === 5 is true
Regexp Checks whether the string matches the pattern /^a/ === "apple" is true
Proc / lambda Calls the proc with the value and uses its truthiness ->(n) { n.even? } === 4 is true
Plain value Falls back to == 5 === 5 is true

Because the very first matching when wins and execution then jumps straight past the rest of the case, there is no fallthrough and no need for a break statement the way you would need one in C, Java, or JavaScript. If a when lists several candidates separated by commas (when 1, 2, 3), the branch runs if any of them matches via ===. If nothing matches and there is no else, the whole expression simply evaluates to nil — a detail that is easy to overlook and worth guarding against deliberately.

case/in, Ruby’s pattern matching form, works differently: instead of testing equality, it tries to structurally match and destructure the subject against each pattern. An array pattern like [a, b] matches any object that responds to deconstruct (arrays do, out of the box) and has the right shape; a hash pattern like { name:, age: } matches any object that responds to deconstruct_keys and has those keys. When a pattern matches, the variables it names are bound as ordinary local variables you can use in that branch’s body and afterward. Patterns can be refined with a guard (in pattern if condition), combined with alternatives (in 1 | 2 | 3), and bound as a whole with => name. Unlike case/when, an unmatched case/in with no else does not return nil quietly — it raises NoMatchingPatternError, which is intentional: pattern matching is meant to be exhaustive and loud about gaps.

Syntax

# General form: case/when
case expression
when value1
  # runs if value1 === expression
when value2, value3
  # runs if value2 === expression or value3 === expression
else
  # runs if nothing above matched
end

# General form: case/in (pattern matching)
case expression
in pattern1
  # runs if expression structurally matches pattern1
in pattern2 if guard_condition
  # pattern2 must match AND guard_condition must be truthy
else
  # runs if nothing above matched (omit to raise NoMatchingPatternError)
end
  • expression — evaluated exactly once, before any comparison happens.
  • when value — the branch runs if value === expression is true; multiple comma-separated values are OR’d together.
  • in pattern — the branch runs if expression structurally matches pattern, binding any variables the pattern names.
  • if guard_condition (case/in only) — an extra boolean check evaluated only after the pattern itself matches.
  • else — optional in case/when (missing else yields nil); strongly recommended in case/in (missing else raises an error) unless you truly want every non-match to be an error.

Examples

Example 1: Basic value and range matching

def describe_number(n)
  case n
  when 0
    "zero"
  when 1..9
    "single digit"
  when 10..99
    "double digit"
  else
    "large number"
  end
end

puts describe_number(0)
puts describe_number(5)
puts describe_number(42)
puts describe_number(1000)

Output:

zero
single digit
double digit
large number

Each call tests n against the when clauses in order. 0 matches the literal 0 first. 5 skips the literal (since 0 === 5 is false) and matches the range 1..9 because Range#=== checks membership. 1000 matches none of the ranges, so the else branch runs.

Example 2: Matching by class

def classify(value)
  case value
  when Integer, Float
    "a number"
  when String
    "a string"
  when Array
    "a collection"
  when nil
    "nothing"
  when Symbol
    "a symbol"
  else
    "something else"
  end
end

[42, 3.14, "hello", [1, 2], nil, :ok].each do |v|
  puts "#{v.inspect} -> #{classify(v)}"
end

Output:

42 -> a number
3.14 -> a number
"hello" -> a string
[1, 2] -> a collection
nil -> nothing
:ok -> a symbol

Here when Integer, Float relies on Module#===, which is Ruby’s built-in “is this an instance of me?” check. Note that nil is deliberately falsy in every other context, but as a when target it is compared with === like any other value — nil === nil is true, so the nil branch matches correctly rather than being skipped.

Example 3: Pattern matching with case/in

def process(event)
  case event
  in { type: "click", x:, y: }
    "click at (#{x}, #{y})"
  in { type: "key", key: String => k }
    "key pressed: #{k}"
  in [Integer, Integer, Integer] => rgb
    "rgb color: #{rgb.join(', ')}"
  in { type: }
    "unhandled event type: #{type}"
  else
    "not an event"
  end
end

puts process({ type: "click", x: 10, y: 20 })
puts process({ type: "key", key: "Enter" })
puts process([255, 0, 128])
puts process({ type: "scroll" })
puts process("nothing")

Output:

click at (10, 20)
key pressed: Enter
rgb color: 255, 0, 128
unhandled event type: scroll
not an event

The first hash { type: "click", x:, y: } requires the literal "click" plus the presence of :x and :y, binding their values as shorthand local variables. The second pattern binds the value at :key to k only if it is a String. The array [255, 0, 128] never matches a hash pattern (arrays don’t respond to deconstruct_keys), so it falls through to the array pattern, which checks three Integer elements and captures the whole array as rgb. { type: "scroll" } fails the first two specific patterns but matches the looser { type: }, which only requires a :type key. The plain string "nothing" can’t deconstruct into either a hash or an array, so it lands in else.

How It Works Step by Step

For case/when: (1) the subject expression is evaluated once and stored; (2) each when‘s candidates are evaluated left to right, and Ruby calls candidate === subject for each; (3) on the first true, that branch’s body runs and its last expression becomes the value of the whole case; (4) if none match, the else body runs, or the result is nil if there is no else.

For case/in: (1) the subject is evaluated once; (2) each in pattern attempts to structurally match it, which may call deconstruct (for array patterns) or deconstruct_keys (for hash patterns) on the subject; (3) if the shape matches, any named parts are bound as local variables in the current scope; (4) if the pattern has a guard (if/unless), it is checked only after a successful structural match; (5) the first pattern that matches (and passes its guard) runs its body; (6) if nothing matches, the else body runs, or NoMatchingPatternError is raised if there is no else.

Common Mistakes

Mistake 1: Using an array literal to mean “any of these values”

def valid_choice?(input)
  case input
  when [1, 2, 3]
    true
  else
    false
  end
end

puts valid_choice?(2)

Output:

false

This looks like it should mean “input is one of 1, 2, or 3,” but Array#=== is just equality: [1, 2, 3] === 2 compares the whole array to the integer 2, which is always false. The when branch can never match. Use a comma-separated list instead, which really does mean “any of these”:

def valid_choice?(input)
  case input
  when 1, 2, 3
    true
  else
    false
  end
end

puts valid_choice?(2)

Output:

true

Mistake 2: Forgetting case/in raises when nothing matches

def match_shape(shape)
  case shape
  in { type: "circle", radius: }
    "circle with radius #{radius}"
  in { type: "square", side: }
    "square with side #{side}"
  end
end

begin
  puts match_shape({ type: "triangle", base: 5, height: 3 })
rescue NoMatchingPatternError => e
  puts "No pattern matched: #{e.class}"
end

Output:

No pattern matched: NoMatchingPatternError

Unlike case/when, which quietly returns nil when nothing matches, case/in without an else raises NoMatchingPatternError for any shape it wasn’t taught to handle. This is a deliberate design choice for exhaustiveness, but it means you must add an else (or wrap the call in a begin/rescue) whenever the input isn’t guaranteed to match one of your patterns.

Mistake 3: Ordering when clauses from general to specific

def grade(score)
  case score
  when 0..100
    "valid score: #{score}"
  when 90..100
    "A grade"
  end
end

puts grade(95)

Output:

valid score: 95

Because 0..100 already covers 95, that broader clause matches first and the more specific 90..100 clause is dead code — it can never be reached for any valid score. Since case/when stops at the first match and never re-checks later clauses, order the specific cases first:

def grade(score)
  case score
  when 90..100
    "A grade"
  when 0..89
    "valid score: #{score}"
  end
end

puts grade(95)

Output:

A grade

Best Practices

  • Reach for case/when for straightforward value, type, or range dispatch; reach for case/in when you need to destructure nested arrays or hashes, such as parsed JSON or API responses.
  • Decide deliberately whether you want a silent nil (add no else to case/when if that’s genuinely fine) or a loud failure (rely on case/in‘s NoMatchingPatternError, or add an explicit else to either form to handle the default case on purpose).
  • Order when/in clauses from most specific to most general, since the first match always wins.
  • Let === do the work: use ranges (1..9) and classes (Integer, String) directly as when targets instead of manually calling include? or is_a? inside an if.
  • In case/in, prefer a guard clause (in pattern if condition) over nesting an if inside the branch body — it keeps the match condition visible at the pattern level.
  • Use the pin operator ^ (for example in ^expected_value) when you want a pattern to match against the current value of an existing variable rather than bind a new one.
  • Remember case is an expression: assign its result directly (label = case x; when ...; end) instead of assigning inside every branch.

Practice Exercises

  1. Write a method season(month) that takes an integer month (1–12) and returns "Winter", "Spring", "Summer", or "Fall" using case/when with ranges. Hint: December (12) belongs with January and February in "Winter", so you’ll need a when clause with more than one range or value.
  2. Write a method shape_area(shape) using case/in that accepts hashes like { type: "rectangle", width: 4, height: 5 } and { type: "circle", radius: 3 }, and returns a string describing the computed area. Include an else branch for unrecognized shapes.
  3. Write a method describe_point(point) that takes a two-element array [x, y] and uses case/in to return "origin" for [0, 0], "on the x-axis" for any [x, 0], "on the y-axis" for any [0, y], and "at (x, y)" otherwise. Expected output for describe_point([0, 5]) is "on the y-axis".

Summary

  • case/when tests each candidate using candidate === subject, not ==; the first match wins and there is no fallthrough.
  • Ranges, classes, regexes, and procs all define their own ===, which is what makes case/when flexible beyond plain literal equality.
  • case/in (stable since Ruby 3.0) performs structural pattern matching, destructuring arrays and hashes via deconstruct/deconstruct_keys and binding matched pieces to local variables.
  • A case/when with no match and no else evaluates to nil silently; a case/in with no match and no else raises NoMatchingPatternError.
  • case is an expression — its value is whatever the matched branch’s last expression evaluates to, so it can be assigned, returned, or interpolated directly.
  • Order matters: put more specific when/in clauses before more general ones, since only the first match ever runs.