Regular Expressions in Ruby
A regular expression (often shortened to “regex”) is a small pattern language for describing the shape of text: “a string that starts with three digits,” “anything that looks like an email address,” “every word that begins with a capital letter.” Ruby treats regular expressions as first-class objects of class Regexp and weaves them directly into String, Array, and Enumerable methods, so pattern matching feels like a native part of the language rather than a bolted-on library. Once you are comfortable with regex, tasks like validating input, pulling structured data out of log files, or rewriting text collapse from dozens of conditionals into a few expressive lines.
Overview / How it works
In Ruby, every regular expression literal you write, such as /hello/, is compiled into an instance of the Regexp class the moment Ruby parses your code. Under the hood, Ruby’s C implementation uses a regex engine called Onigmo (a fork of Oniguruma) to actually execute the pattern against a string. That engine is a backtracking engine: it tries to match the pattern starting at each position in the string, and whenever a later part of the pattern fails to match, it backtracks and tries a different way of satisfying the earlier part (this is exactly why greedy quantifiers can behave surprisingly, covered in Common Mistakes below).
You can build a Regexp three ways: a literal between slashes (/pattern/), a literal using %r{...} delimiters (handy when the pattern itself contains slashes), or dynamically with Regexp.new("pattern"). All three produce ordinary Ruby objects that respond to methods like #match, #match?, and #=~.
When a match succeeds, Ruby produces a MatchData object that holds the full match, every capture group (numbered starting at 1, group 0 is the whole match), and any named captures. Using the =~ operator or #match also sets a family of special global variables such as $~ (the last MatchData), $1, $2, and so on for the numbered captures. Because these are true globals, relying on them inside multi-threaded or deeply nested code can be fragile — prefer capturing the MatchData into a local variable when you can.
Regex also plugs into Ruby’s truthiness and case-matching machinery. Regexp#=== is defined so that some_regex === some_string returns true or false, which is exactly the method Ruby calls behind the scenes in a case/when statement — this is why you can write when /^\d+$/ directly as a when clause. Remember Ruby’s truthiness rule while working with regex: only nil and false are falsy, so a MatchData object, an empty string match, or the integer 0 returned by =~ all count as truthy — a match at index 0 will still enter an if branch correctly.
Syntax
The three ways to construct a pattern, and the options flags you can append after the closing delimiter:
pattern1 = /hello/i
pattern2 = %r{hello}i
pattern3 = Regexp.new("hello", Regexp::IGNORECASE)
puts pattern1.class
puts pattern1 == pattern2
puts pattern1 == pattern3
Output:
Regexp
true
true
| Flag | Meaning |
|---|---|
i |
Case-insensitive matching |
m |
Multiline mode — makes . also match newline characters |
x |
Extended mode — whitespace and # comments in the pattern are ignored, for readability |
Key metacharacters and quantifiers you will use constantly:
| Token | Meaning |
|---|---|
. |
Any character except a newline |
\d / \D |
A digit / a non-digit |
\w / \W |
A word character (letters, digits, underscore) / not a word character |
\s / \S |
Whitespace / non-whitespace |
^ / $ |
Start / end of a line |
\A / \z |
Start / absolute end of the whole string (safer than ^/$ for full-string checks) |
*, +, ? |
0-or-more, 1-or-more, 0-or-1 of the preceding token |
{n,m} |
Between n and m repetitions |
(...) |
Capture group |
(?<name>...) |
Named capture group |
(?:...) |
Non-capturing group (grouping without saving the match) |
| |
Alternation (“or”) |
[abc] / [^abc] |
Character class / negated character class |
Examples
Example 1: Basic matching with =~ and match?
text = "The quick brown fox jumps over the lazy dog"
if text =~ /quick/
puts "Match found at index #{$~.begin(0)}"
else
puts "No match"
end
puts text.match?(/lazy/)
puts text.match?(/purple/)
Output:
Match found at index 4
true
false
=~ returns the integer index of the first match (or nil) and also populates $~ with the full MatchData, which is why $~.begin(0) works afterward. match? is the method to reach for when you only care about a yes/no answer: it returns a plain boolean, does not allocate a MatchData, and does not touch the global variables — it is also the fastest of the three for a simple existence check.
Example 2: Capture groups and named captures
log_line = "2026-08-10 14:32:07 ERROR Database connection failed"
if match = log_line.match(/(?<date>\d{4}-\d{2}-\d{2}) (?<time>\d{2}:\d{2}:\d{2}) (?<level>\w+) (?<message>.+)/)
puts "Date: #{match[:date]}"
puts "Time: #{match[:time]}"
puts "Level: #{match[:level]}"
puts "Message: #{match[:message]}"
end
Output:
Date: 2026-08-10
Time: 14:32:07
Level: ERROR
Message: Database connection failed
String#match returns a MatchData object (or nil if nothing matched), which is why it is safe here to assign it directly into the if condition — a failed match is nil, which is falsy, so the block is skipped cleanly. Named captures, written with (?<name>...), let you pull pieces out by a meaningful key (match[:date]) instead of a fragile numeric index (match[1]), which is especially valuable once a pattern has more than two or three groups.
Example 3: scan and gsub with a block
sentence = "Ruby costs $25, Python costs $30, and Java costs $28"
prices = sentence.scan(/\$(\d+)/).flatten
puts "Prices found: #{prices.join(', ')}"
total = prices.sum(&:to_i)
puts "Total: $#{total}"
discounted = sentence.gsub(/\$(\d+)/) { "$#{$1.to_i - 5}" }
puts discounted
Output:
Prices found: 25, 30, 28
Total: $83
Ruby costs $20, Python costs $25, and Java costs $23
scan returns every match in the string; because the pattern has one capture group, each result is a one-element array, so .flatten turns [["25"], ["30"], ["28"]] into ["25", "30", "28"]. gsub with a block replaces every match by running the block for each one; inside the block, $1 refers to that particular match’s first capture, letting you compute a new value (here, five dollars off) for every occurrence in a single pass.
How it works step by step
Walking through what Ruby does for "abc123".match(/([a-z]+)(\d+)/):
- Ruby parses the literal
/([a-z]+)(\d+)/at compile time into aRegexpobject with two capture groups. - Calling
.matchhands the string and the compiled pattern to the Onigmo engine. - The engine tries to find a match starting at index 0.
[a-z]+greedily consumes as many lowercase letters as possible (“abc”), then\d+greedily consumes as many digits as possible (“123”). - Because the whole pattern matched, no backtracking is needed and the engine stops. If it had failed partway through, it would backtrack — giving back one character to an earlier greedy match and retrying the rest of the pattern.
- Ruby builds a
MatchDataobject recording the overall match ("abc123") and each group ("abc","123"), and returns it.
Common Mistakes
Mistake 1: Greedy quantifiers eating too much
Quantifiers like * and + are greedy by default — they try to consume as much text as possible before backtracking. With markup or delimited text, that often matches far more than intended:
html = "<b>bold</b> and <i>italic</i>"
match = html.match(/<.*>/)
puts match[0]
Output:
<b>bold</b> and <i>italic</i>
The intent was to grab one tag like <b>, but .* greedily stretches from the very first < all the way to the last > in the string. Adding ? after a quantifier makes it lazy (match as little as possible):
html = "<b>bold</b> and <i>italic</i>"
match = html.match(/<.*?>/)
puts match[0]
Output:
<b>
Mistake 2: Forgetting that . matches any character
An unescaped . in a pattern matches literally any character, not just a literal period — a very common source of false positives:
filenames = ["report.pdf", "reportXpdf", "report.txt"]
pdf_files = filenames.select { |f| f =~ /report.pdf/ }
puts pdf_files.inspect
Output:
["report.pdf", "reportXpdf"]
"reportXpdf" should never have matched, but the unescaped . happily treated the X as “any character.” Escape the period with a backslash when you mean a literal dot:
filenames = ["report.pdf", "reportXpdf", "report.txt"]
pdf_files = filenames.select { |f| f =~ /report\.pdf/ }
puts pdf_files.inspect
Output:
["report.pdf"]
Best Practices
- Use
match?instead ofmatchor=~when you only need a true/false answer — it is faster and does not pollute the$~/$1globals. - Prefer named captures (
(?<name>...)) over numbered ones once a pattern has more than one or two groups;match[:name]is far more readable thanmatch[3]and survives someone reordering the groups later. - Anchor with
\Aand\zinstead of^and$when validating an entire string —^/$match at line boundaries, so a crafted multi-line input can slip past a^...$check. - Escape any character that is meaningful to regex (
. * + ? ( ) [ ] { } | ^ $ \) when you want it matched literally; useRegexp.escape(str)to safely escape a dynamic string before interpolating it into a pattern. - Reach for the
x(extended) flag on long or complex patterns so you can spread them across lines with comments instead of writing an unreadable wall of symbols. - Use non-capturing groups
(?:...)when you need grouping or alternation but do not need to extract that piece — it keeps capture numbering predictable. - For simple literal searches (no metacharacters), a plain
String#include?is clearer and faster than a regex — save regex for patterns that actually need it.
Practice Exercises
- Write a method
valid_hex_color?(str)that returnstrueifstris a valid hex color like"#1a2b3c"or"#FFF"(either 3 or 6 hex digits after the#), andfalseotherwise. Anchor the pattern so"#1a2b3cXX"is rejected. - Given the string
"alice@example.com, bob@test.org, not-an-email", usescanwith a regex to extract just the two valid-looking email addresses into an array. - Write a method that takes a sentence and uses
gsubto capitalize the first letter of every word (hint: match\b\wand use a block that callsupcaseon the matched letter). For input"the ruby language", the expected output is"The Ruby Language".
Summary
- Regex literals compile to
Regexpobjects; Ruby’s Onigmo engine executes them using backtracking. match?gives a fast boolean;match/=~return aMatchData(or index) and set globals like$~and$1.- Use
(?<name>...)named captures for readable extraction, and(?:...)when you need grouping without capturing. - Quantifiers are greedy by default; append
?to make them lazy when you need the shortest match. scancollects every match,sub/gsubreplace the first/all matches (optionally via a block using$1), andsplitdivides a string on a pattern.- Always escape literal metacharacters like
., and prefer\A/\zover^/$for whole-string validation.
