Pattern Matching in Ruby
Pattern matching is one of the most transformative features added to Ruby in recent versions. Instead of writing nested conditionals and type checks, pattern matching lets you destructure data and match against specific shapes in a single expression. This declarative approach makes your code more readable and reduces the cognitive overhead of understanding complex data flows.
Ruby's pattern matching was introduced in Ruby 2.7 as an experimental feature and became stable in Ruby 3.0. Unlike traditional case statements that only match values, pattern matching can match against data structure shapes, extract nested values, and apply guards all in one expression. The feature draws inspiration from functional languages while maintaining Ruby's characteristic readability.
In this tutorial, you'll learn:
- How pattern matching differs from traditional case statements
- How to match against arrays, hashes, and objects
- How to extract and bind values during matching
- How to use guards and alternative patterns effectively
Prerequisites
This guide assumes you have Ruby 3.0 or newer installed on your system. Pattern matching works in Ruby 2.7, but some syntax improvements and features require Ruby 3.0+. You should understand basic Ruby syntax, including case statements, arrays, and hashes.
Understanding pattern matching fundamentals
Pattern matching in Ruby uses the case
/in
syntax instead of the traditional case
/when
structure. The key difference is that in
clauses match against patterns rather than just comparing values:
mkdir ruby-patterns && cd ruby-patterns
# Traditional case statement
value = 5
result = case value
when 1, 2, 3
"small"
when 4, 5, 6
"medium"
else
"large"
end
puts result
# Pattern matching
result = case value
in 1 | 2 | 3
"small"
in 4 | 5 | 6
"medium"
else
"large"
end
puts result
The first case statement shows Ruby's traditional matching behavior, which compares values using the ===
operator. This works well for simple value matching but becomes cumbersome when you need to inspect data structures. The pattern matching version on line 14 uses in
instead of when
, and the pipe operator |
indicates alternative patterns. While this example looks similar to traditional case statements, pattern matching's real power emerges when working with structured data.
ruby app.rb
medium
medium
The output shows both approaches producing identical results for simple value matching. Pattern matching really distinguishes itself when you need to look inside complex data structures, which we'll explore next.
Now that you understand the basic syntax, let's see how pattern matching handles array structures.
Matching array patterns
Pattern matching excels at destructuring arrays and matching their contents:
def describe_array(arr)
case arr
in []
"empty array"
in [x]
"single element: #{x}"
in [first, second]
"two elements: #{first} and #{second}"
in [first, *rest]
"starts with #{first}, followed by #{rest.length} more"
end
end
puts describe_array([])
puts describe_array([42])
puts describe_array([1, 2])
puts describe_array([1, 2, 3, 4])
The third line matches an empty array explicitly. Lines 5 and 7 demonstrate exact length matching - Ruby only enters these branches when the array has exactly one or two elements respectively. The pattern on line 9 introduces the splat operator *rest
, which captures any remaining elements. This is particularly useful when you care about specific positions but want to handle variable-length arrays. The variable names (x
, first
, second
, rest
) automatically bind to the matched values, making them available in the branch body.
ruby app.rb
empty array
single element: 42
two elements: 1 and 2
starts with 1, followed by 3 more
The output confirms that pattern matching correctly identifies array shapes and extracts values. Notice how the last case captures the first element while grouping the remaining three elements into the rest
array.
Beyond simple matching, you can also specify exact values that must appear in specific positions.
Matching specific values in arrays
You can combine structural matching with value matching to create precise patterns:
def analyze_coordinates(point)
case point
in [0, 0]
"origin"
in [0, y]
"on y-axis at #{y}"
in [x, 0]
"on x-axis at #{x}"
in [x, y]
"point at (#{x}, #{y})"
end
end
puts analyze_coordinates([0, 0])
puts analyze_coordinates([0, 5])
puts analyze_coordinates([3, 0])
puts analyze_coordinates([3, 4])
The third line shows how to match exact values - this pattern only matches when both elements are zero. Lines 5 and 7 demonstrate partial value matching: [0, y]
matches arrays where the first element is exactly 0, while binding the second element to the variable y
. The final pattern on line 9 acts as a catch-all for any two-element array, binding both values to variables. Ruby evaluates patterns in order, so more specific patterns should come before general ones.
ruby app.rb
origin
on y-axis at 5
on x-axis at 3
point at (3, 4)
The output shows how pattern matching routes each coordinate pair to the appropriate branch. The origin case matches before the more general patterns, demonstrating the importance of pattern ordering.
While arrays are straightforward, hash patterns introduce additional flexibility for working with key-value data.
Matching hash patterns
Hash pattern matching is particularly powerful because it ignores extra keys by default:
def process_user(user)
case user
in { name: "Admin" }
"Administrator access"
in { name: name, role: "moderator" }
"Moderator: #{name}"
in { name: name, age: age } if age >= 18
"Adult user: #{name}"
in { name: name }
"User: #{name}"
end
end
puts process_user({ name: "Admin", email: "admin@example.com" })
puts process_user({ name: "Alice", role: "moderator", level: 5 })
puts process_user({ name: "Bob", age: 25, verified: true })
puts process_user({ name: "Charlie", age: 16 })
The third line matches any hash containing name: "Admin"
, regardless of what other keys exist. This is fundamentally different from exact hash comparison and makes pattern matching incredibly practical for real-world data. Line 5 shows simultaneous value matching and variable binding - we match the exact string "moderator" for role while capturing the name value. The guard clause on line 7 (if age >= 18
) adds conditional logic beyond structural matching. Guards evaluate after the pattern matches, allowing you to add arbitrary conditions.
ruby app.rb
Administrator access
Moderator: Alice
Adult user: Bob
User: Charlie
The output demonstrates hash matching in action. Notice how Charlie's hash includes an age key but fails the guard condition, falling through to the general user pattern instead.
Sometimes you need to ensure a hash contains only specific keys, which requires a different approach.
Exact hash matching
When you need to match hashes precisely without allowing extra keys, use the **nil
pattern:
def validate_config(config)
case config
in { host: host, port: port, **nil }
"Valid config: #{host}:#{port}"
in { host: host, port: port }
"Config has unexpected keys"
end
end
puts validate_config({ host: "localhost", port: 3000 })
puts validate_config({ host: "localhost", port: 3000, timeout: 30 })
The third line uses **nil
to indicate that no additional keys should be present. This pattern only matches when the hash contains exactly host
and port
and nothing else. Without **nil
, hash patterns are "open" by default, matching even when extra keys exist (as shown on line 5). This distinction is crucial when validating input or working with strict APIs that shouldn't accept unknown parameters.
ruby app.rb
Valid config: localhost:3000
Config has unexpected keys
The output shows the difference: the first hash matches the strict pattern, while the second hash fails because of the extra timeout
key and falls through to the warning branch.
Pattern matching isn't limited to built-in types - it works beautifully with custom objects too.
Matching object patterns
Pattern matching can destructure custom objects by calling their methods:
class Point
attr_reader :x, :y
def initialize(x, y)
@x = x
@y = y
end
def deconstruct
[x, y]
end
def deconstruct_keys(keys)
{ x: x, y: y }
end
end
point = Point.new(3, 4)
case point
in Point(x: 0, y: 0)
puts "Origin"
in Point(x: x, y: y)
puts "Point at (#{x}, #{y})"
end
Lines 9-11 define the deconstruct
method, which Ruby calls when matching array-like patterns. The method returns an array of values that represent the object's structure. Lines 13-15 define deconstruct_keys
, which Ruby uses for hash-like patterns. By implementing these methods, you control exactly how your objects participate in pattern matching. The pattern on line 21 uses hash-style matching with the class name as a prefix, calling deconstruct_keys
behind the scenes.
ruby app.rb
Point at (3, 4)
The output shows successful object pattern matching. Ruby automatically calls deconstruct_keys
when it encounters the Point(x: x, y: y)
pattern, extracting the values through the defined interface.
Beyond simple matches, you can use patterns to transform and validate nested data structures.
Working with nested patterns
Pattern matching really shines when dealing with nested data structures:
def process_response(response)
case response
in { status: 200, data: { user: { name: name, email: email } } }
"Success: #{name} (#{email})"
in { status: 200, data: data }
"Success: #{data}"
in { status: code, error: message } if code >= 400
"Error #{code}: #{message}"
in { status: code }
"Response code: #{code}"
end
end
response1 = { status: 200, data: { user: { name: "Alice", email: "alice@example.com" } } }
response2 = { status: 200, data: [1, 2, 3] }
response3 = { status: 404, error: "Not found" }
puts process_response(response1)
puts process_response(response2)
puts process_response(response3)
The third line demonstrates deep pattern matching - we're matching not just the outer hash structure, but diving three levels deep to extract name
and email
from the nested user object. This would require multiple conditional checks and nil guards with traditional approaches. The pattern on line 5 shows a fallback for successful responses that don't match the expected nested structure. Combining nested patterns with guards (line 7) creates sophisticated matching logic while keeping the code readable.
ruby app.rb
Success: Alice (alice@example.com)
Success: [1, 2, 3]
Error 404: Not found
The output demonstrates handling three different response shapes with a single case expression. The first response matches the deeply nested pattern, while the others fall through to their appropriate branches.
Sometimes you need to capture the entire matched value while also destructuring it, which the as
pattern handles elegantly.
Using the as pattern
The =>
operator lets you capture a matched value while simultaneously destructuring it:
def analyze_request(request)
case request
in { method: "GET", path: path => p }
"GET request: #{p}"
in { method: method, params: params } => full_request
"#{method} with params: #{params}"
puts "Full request: #{full_request.inspect}"
end
end
puts analyze_request({ method: "GET", path: "/users" })
puts analyze_request({ method: "POST", params: { name: "Alice" }, headers: {} })
The third line uses => p
to capture the path value into the variable p
after extracting it. This might seem redundant here since we're already binding it to path
, but it becomes useful when matching complex nested structures where you want both the extracted value and the ability to reference it differently. Line 5 demonstrates capturing the entire matched hash with => full_request
, giving you access to both the destructured values and the original structure. This is particularly valuable when you need to log or pass along the complete data while working with specific fields.
ruby app.rb
GET request: /users
Full request: {method: "POST", params: {name: "Alice"}, headers: {}}
The output shows both the destructured values and the full request object. The as
pattern provides flexibility when you need multiple views of the same data.
Pattern matching also supports the pin operator for matching against existing variables rather than creating new bindings.
Pinning variables in patterns
The pin operator ^
tells Ruby to match against an existing variable's value instead of binding:
def check_access(user, required_role)
case user
in { role: ^required_role, active: true }
"Access granted"
in { role: ^required_role }
"Account inactive"
in { role: role }
"Access denied: has #{role}, needs #{required_role}"
end
end
puts check_access({ role: "admin", active: true }, "admin")
puts check_access({ role: "admin", active: false }, "admin")
puts check_access({ role: "user", active: true }, "admin")
The third line uses ^required_role
to match against the value stored in the required_role
parameter. Without the pin operator, Ruby would create a new variable named required_role
and bind the hash value to it, which isn't what we want. The pin operator is essential when you need to compare against dynamic values rather than literal strings or numbers. Line 7 shows a regular binding without the pin operator, which captures whatever role value exists for display purposes.
ruby app.rb
Access granted
Account inactive
Access denied: has user, needs admin
The output demonstrates pinned variable matching. The first two cases match because the role matches the required value, while the third fails the pinned match and captures the actual role for the error message.
Beyond these core features, Ruby provides alternative ways to handle pattern matching failures.
Handling non-matching cases
Pattern matching raises an exception if no pattern matches unless you provide an else
clause:
def safe_process(value)
case value
in Integer => n if n > 0
"Positive: #{n}"
in Integer => n if n < 0
"Negative: #{n}"
else
"Zero or non-integer"
end
end
def unsafe_process(value)
case value
in Integer => n if n > 0
"Positive: #{n}"
in Integer => n if n < 0
"Negative: #{n}"
end
end
puts safe_process(5)
puts safe_process("hello")
begin
puts unsafe_process(0)
rescue NoMatchingPatternError => e
puts "Error: #{e.message}"
end
The first function includes an else
clause on line 7, which catches any values that don't match the previous patterns. This is the safe approach for pattern matching - always include an else
or ensure your patterns are exhaustive. The second function (line 12) deliberately omits the else
clause to show what happens when no pattern matches. Ruby raises a NoMatchingPatternError
, which you can rescue if needed, but it's better to handle all cases explicitly.
ruby app.rb
Positive: 5
Zero or non-integer
Error: 0
The output shows the else
clause catching unexpected values, while the unsafe version raises an error when encountering zero. Always providing an else
clause prevents runtime errors and makes your code more robust.
For simple one-line pattern matching, Ruby offers a rightward assignment operator that's perfect for destructuring.
Using rightward assignment
Ruby 3.0 introduced the rightward assignment operator =>
for one-line pattern matching:
# Destructure an array
[1, 2, 3] => [first, *rest]
puts "First: #{first}, Rest: #{rest.inspect}"
# Destructure a hash
{ name: "Alice", age: 30 } => { name: name, age: age }
puts "Name: #{name}, Age: #{age}"
# With objects
Point = Struct.new(:x, :y)
Point.new(5, 10) => Point(x, y)
puts "Coordinates: (#{x}, #{y})"
The second line shows rightward assignment destructuring an array. This is equivalent to a pattern match but doesn't require a case statement for simple destructuring operations. The syntax reads naturally: take this value and match it against this pattern, binding the variables. Line 6 demonstrates hash destructuring, which is particularly useful for extracting specific keys from larger hashes. The rightward assignment raises a NoMatchingPatternError
if the pattern doesn't match, so use it when you're confident about the data structure.
ruby app.rb
First: 1, Rest: [2, 3]
Name: Alice, Age: 30
Coordinates: (5, 10)
The output confirms successful destructuring across different data types. Rightward assignment provides a concise syntax when you just need to extract values without conditional branching.
Final thoughts
Pattern matching fundamentally changes how you handle complex data in Ruby. Instead of writing defensive code full of type checks and nil guards, you can declare the shapes you expect and let Ruby handle the matching. This declarative approach reduces bugs and makes your intentions immediately clear to anyone reading the code.
The key to effective pattern matching is starting simple and gradually incorporating more advanced features as needed. Begin with basic array and hash patterns, then introduce guards and nested matching as your confidence grows. To deepen your understanding, explore Ruby's official pattern matching documentation and experiment with refactoring existing conditional logic into pattern matching expressions.