Back to Scaling Ruby Applications guides

Pattern Matching in Ruby

Stanley Ulili
Updated on October 14, 2025

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
app.rb
# 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
Output
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:

app.rb
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
Output
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:

app.rb
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
Output
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:

app.rb
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
Output
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:

app.rb
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
Output
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:

app.rb
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
Output
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:

app.rb
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
Output
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:

app.rb
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
Output
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:

app.rb
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
Output
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:

app.rb
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
Output
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:

app.rb
# 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
Output
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.