Pattern Matching with match and scan
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:
patternis aRegexpliteral (/.../) or an interpolatedRegexp.new(...); passing a plainStringtomatchormatch?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.Regexpalso definesmatch, sopattern.match(string)works identically tostring.match(pattern).- Capture groups are written with parentheses
(...); named captures use(?<name>...)and are read back withmatch_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 ofmatchwhen you only need a true/false answer — it skips allocating aMatchDataand is noticeably faster in hot loops. - Use
scanwhenever you need every occurrence;matchand=~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 thanmatch_data[1]. - Anchor with
\Aand\zwhen you mean the whole string; reserve^and$for genuine line-by-line checks. - Guard against
nilbefore indexing into amatchresult — anifcheck ortext.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 theMatchDatain a local variable instead.
Practice Exercises
- Given
"alice@example.com, bob@test.org, carol@demo.net", usescanto extract just the usernames before each@into an array. Expected output:["alice", "bob", "carol"]. - Write a method
has_number?(str)that usesmatch?(notmatch) to returntrueif a string contains at least one digit, andfalseotherwise. - Given
"2026-08-10: deploy succeeded\n2026-08-11: deploy failed", usescanwith a capture-group pattern to build an array of[date, status]pairs, then print each pair on its own line.
Summary
matchand=~both find only the first occurrence;matchreturns aMatchData(ornil), while=~returns an index (ornil) and sets global variables like$~.match?returns a plaintrue/falsewithout allocating aMatchData, making it the fastest option for yes/no checks.scanreturns every non-overlapping match as an array — strings when the pattern has no groups, arrays of groups when it does.- A
MatchDataexposes the whole match ([0]), numbered groups ([1],[2]…), named groups ([:name]), and the surrounding text viapre_match/post_match. nilis 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;\Aand\zanchor to the true start and end of the string — use the latter when you mean the whole string.
