case Expressions
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 ifvalue === expressionis true; multiple comma-separated values are OR’d together.in pattern— the branch runs ifexpressionstructurally matchespattern, 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 incase/when(missing else yieldsnil); strongly recommended incase/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/whenfor straightforward value, type, or range dispatch; reach forcase/inwhen you need to destructure nested arrays or hashes, such as parsed JSON or API responses. - Decide deliberately whether you want a silent
nil(add noelsetocase/whenif that’s genuinely fine) or a loud failure (rely oncase/in‘sNoMatchingPatternError, or add an explicitelseto either form to handle the default case on purpose). - Order
when/inclauses 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 aswhentargets instead of manually callinginclude?oris_a?inside anif. - In
case/in, prefer a guard clause (in pattern if condition) over nesting anifinside the branch body — it keeps the match condition visible at the pattern level. - Use the pin operator
^(for examplein ^expected_value) when you want a pattern to match against the current value of an existing variable rather than bind a new one. - Remember
caseis an expression: assign its result directly (label = case x; when ...; end) instead of assigning inside every branch.
Practice Exercises
- Write a method
season(month)that takes an integer month (1–12) and returns"Winter","Spring","Summer", or"Fall"usingcase/whenwith ranges. Hint: December (12) belongs with January and February in"Winter", so you’ll need awhenclause with more than one range or value. - Write a method
shape_area(shape)usingcase/inthat accepts hashes like{ type: "rectangle", width: 4, height: 5 }and{ type: "circle", radius: 3 }, and returns a string describing the computed area. Include anelsebranch for unrecognized shapes. - Write a method
describe_point(point)that takes a two-element array[x, y]and usescase/into 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 fordescribe_point([0, 5])is"on the y-axis".
Summary
case/whentests each candidate usingcandidate === subject, not==; the first match wins and there is no fallthrough.- Ranges, classes, regexes, and procs all define their own
===, which is what makescase/whenflexible beyond plain literal equality. case/in(stable since Ruby 3.0) performs structural pattern matching, destructuring arrays and hashes viadeconstruct/deconstruct_keysand binding matched pieces to local variables.- A
case/whenwith no match and noelseevaluates tonilsilently; acase/inwith no match and noelseraisesNoMatchingPatternError. caseis 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/inclauses before more general ones, since only the first match ever runs.
