Pattern Matching with match and scan

Published by Programming Line in Ruby. Last updated .

When you need to find a phone number buried in a string, pull every email address out of a log file, or check whether a line matches a pattern before doing something with it, Ruby’s regular expression methods — match, match?, scan, and =~ — are the tools for the job. Each one asks a slightly different question of a string and a Regexp, and picking the right one is the difference between clean, readable code and code that crashes on a stray nil. This lesson covers all four in depth, including the MatchData object they hand back and the classic mistakes that trip people up.

Overview: How Pattern Matching Works in Ruby

Ruby ships with a full-featured regular expression engine (Onigmo) exposed through the Regexp class. A regexp literal like /\d+/ is a real object — you can store it in a variable, pass it to a method, or call .source on it to see the pattern text. Matching that object against a string is where match, match?, scan, and =~ come in, and each answers a different question.

match asks: "does this pattern occur in the string, and if so, exactly what matched?" It returns a MatchData object on success or nil on failure. It scans left to right and stops at the very first position where the pattern succeeds — it never looks for a second occurrence.

match? asks the yes/no version of the same question. It returns true or false and, importantly, never allocates a MatchData object or touches the $~ global variable. That makes it measurably faster when you only care whether something matched, not what or where.

=~ is the older, terser operator form. Written as string =~ regexp (or regexp =~ string), it returns the integer index of the first match, or nil. As a side effect it populates global variables: $~ holds the MatchData, $1, $2, etc. hold the capture groups, and $&, $`, $' hold the whole match, pre-match, and post-match text.

scan asks a completely different question: "every place this pattern occurs, what did each one capture?" It always returns an Array — of matched strings when the pattern has no capture groups, or of arrays of captured groups when it does. Internally, scan repeats a find-and-advance cycle: locate a match, record it, move the search position to just past the end of that match, and repeat until no more matches are found. Because the search position always advances past the previous match, results never overlap.

This all ties back to one of Ruby’s core rules: only nil and false are falsy — 0, "", and [] are all truthy. A successful match always returns a real, truthy MatchData object (even a zero-length match at position 0 is still a truthy object), and only a failed match produces the falsy nil. That single rule is why if str.match(/x/) reads naturally and behaves correctly in Ruby.

Syntax

Form Returns on match Returns on no match
string.match(pattern) MatchData nil
string.match?(pattern) true false
string =~ pattern Integer (start index) nil
string.scan(pattern) Array of matches [] (empty array, never nil)

Notes on the pieces:

  • pattern is a Regexp literal (/.../) or an interpolated Regexp.new(...); passing a plain String to match or match? is also allowed — Ruby converts it to a regexp internally.
  • string.match(pattern, start_position) accepts an optional second argument telling it where in the string to begin searching.
  • Regexp also defines match, so pattern.match(string) works identically to string.match(pattern).
  • Capture groups are written with parentheses (...); named captures use (?<name>...) and are read back with match_data[:name] instead of a numeric index.

Examples

Example 1: Basic match and the MatchData object

text = "My phone number is 555-1234."
result = text.match(/\d{3}-\d{4}/)
puts result[0]
puts result.pre_match
puts result.post_match

Output:

555-1234
My phone number is 
.

The regexp /\d{3}-\d{4}/ matches three digits, a hyphen, and four digits. match returns a MatchData, and result[0] gives the entire matched substring (the same as result.to_s). pre_match returns everything in the original string before the match started, and post_match returns everything after it ended — here just the trailing period.

Example 2: scan without and with capture groups

sentence = "cat bat hat mat"
words = sentence.scan(/\w+at/)
puts words.inspect

data = "name: Alice, age: 30, name: Bob, age: 25"
pairs = data.scan(/(\w+): (\w+)/)
puts pairs.inspect

Output:

["cat", "bat", "hat", "mat"]
[["name", "Alice"], ["age", "30"], ["name", "Bob"], ["age", "25"]]

Without any capture groups, scan returns a flat array of the full matched strings. As soon as the pattern has capture groups — (\w+): (\w+) here — scan switches to returning an array of arrays, one sub-array per match holding exactly what each group captured. This shape change is easy to forget and is covered again below.

Example 3: named captures with match, plus match?

log_line = "2026-08-10 ERROR Connection timed out"
match_data = log_line.match(/(?<date>\d{4}-\d{2}-\d{2}) (?<level>\w+) (?<message>.+)/)

if match_data
  puts "Date: #{match_data[:date]}"
  puts "Level: #{match_data[:level]}"
  puts "Message: #{match_data[:message]}"
end

puts log_line.match?(/ERROR|WARN/)
puts log_line.match?(/INFO/)

Output:

Date: 2026-08-10
Level: ERROR
Message: Connection timed out
true
false

Named captures ((?<date>...)) let you pull pieces out by meaningful name instead of counting parentheses. The last two lines show match? answering a plain true/false question without building a MatchData at all — useful when a log processor only needs to route a line, not inspect it.

How It Works Step by Step

For a single match call, Ruby’s regex engine starts at index 0 of the string and tries the pattern there. If it fails, it advances to index 1 and tries again, and so on, until the pattern succeeds somewhere or the engine runs out of string (giving nil). The moment it succeeds, matching stops — that’s why match only ever reports the first hit.

scan builds on that same single-match mechanism in a loop: (1) search for a match starting from the current position; (2) if found, capture it (either the whole match or its capture groups) into the results array; (3) move the current position to just past the end of that match; (4) repeat from step 1. When step 1 finds nothing more, the loop ends and the accumulated array is returned. This is also why a pattern that can match an empty string needs care — Ruby’s scan still advances at least one character per iteration to avoid looping forever.

A MatchData object, once you have one, is a snapshot: it remembers the original string, the overall match, every capture group (numbered and named), and the text before and after the match. Indexing it with [0] gives the whole match, [1], [2], etc. give numbered groups in order of their opening parenthesis, and [:name] gives a named group. to_a returns the whole match plus all groups as an array; captures returns just the groups (no whole match).

Common Mistakes

Mistake 1: Indexing into a nil result

Because match returns nil when nothing matches, calling [0] on that result blows up:

text = "Hello world"
result = text.match(/\d+/)
puts result[0].upcase

Output:

(raises NoMethodError: undefined method '[]' for nil, because "Hello world" has no digits)

There are no digits in "Hello world", so result is nil, and nil[0] is not a valid call. Always check the result first, or use safe navigation:

text = "Hello world"
result = text.match(/\d+/)

if result
  puts result[0].upcase
else
  puts "No match found"
end

Output:

No match found

Mistake 2: Using match when you meant scan

text = "apple 10, banana 20, cherry 30"
result = text.match(/\d+/)
puts result[0]

Output:

10

match only ever finds the first occurrence, so the two later numbers are silently ignored — a common surprise for anyone expecting "find all the numbers" behavior. Use scan to collect every match:

text = "apple 10, banana 20, cherry 30"
numbers = text.scan(/\d+/)
puts numbers.inspect

Output:

["10", "20", "30"]

Mistake 3: Assuming ^ and $ anchor the whole string

comment = "This is fine\nDROP TABLE users -- gotcha"
puts comment.match?(/^This/)
puts comment.match?(/^DROP/)

Output:

true
true

Both print true, which surprises people coming from languages where ^/$ mean "start/end of the whole string." In Ruby, ^ and $ match the start and end of any line within a multi-line string, so /^DROP/ matches even though the string doesn’t start with DROP — the second line does. Use \A and \z to anchor to the actual start and end of the whole string:

comment = "This is fine\nDROP TABLE users -- gotcha"
puts comment.match?(/\AThis/)
puts comment.match?(/\ADROP/)

Output:

true
false

Best Practices

  • Reach for match? instead of match when you only need a true/false answer — it skips allocating a MatchData and is noticeably faster in hot loops.
  • Use scan whenever you need every occurrence; match and =~ only ever find the first one.
  • Prefer named captures ((?<name>...)) over numbered groups in anything beyond a trivial pattern — match_data[:date] documents itself far better than match_data[1].
  • Anchor with \A and \z when you mean the whole string; reserve ^ and $ for genuine line-by-line checks.
  • Guard against nil before indexing into a match result — an if check or text.match(/.../)&.then { |m| m[0] }-style safe navigation avoids a crash on no match.
  • Remember that scan‘s return shape depends on the pattern: no groups means an array of strings, one or more groups means an array of arrays (or of hashes-like named captures accessed via a block).
  • Avoid leaning on $~, $1, and friends across multiple lines of logic — they’re overwritten by the next match and are easy to misread; prefer capturing the MatchData in a local variable instead.

Practice Exercises

  • Given "alice@example.com, bob@test.org, carol@demo.net", use scan to extract just the usernames before each @ into an array. Expected output: ["alice", "bob", "carol"].
  • Write a method has_number?(str) that uses match? (not match) to return true if a string contains at least one digit, and false otherwise.
  • Given "2026-08-10: deploy succeeded\n2026-08-11: deploy failed", use scan with a capture-group pattern to build an array of [date, status] pairs, then print each pair on its own line.

Summary

  • match and =~ both find only the first occurrence; match returns a MatchData (or nil), while =~ returns an index (or nil) and sets global variables like $~.
  • match? returns a plain true/false without allocating a MatchData, making it the fastest option for yes/no checks.
  • scan returns every non-overlapping match as an array — strings when the pattern has no groups, arrays of groups when it does.
  • A MatchData exposes the whole match ([0]), numbered groups ([1], [2]…), named groups ([:name]), and the surrounding text via pre_match/post_match.
  • nil is the only falsy signal these methods return — a successful match is always truthy, even an empty-string or zero-index match.
  • ^ and $ anchor to line boundaries; \A and \z anchor to the true start and end of the string — use the latter when you mean the whole string.