Regular Expressions in Ruby

Published by Programming Line in Ruby. Last updated .

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+)/):

  1. Ruby parses the literal /([a-z]+)(\d+)/ at compile time into a Regexp object with two capture groups.
  2. Calling .match hands the string and the compiled pattern to the Onigmo engine.
  3. 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”).
  4. 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.
  5. Ruby builds a MatchData object 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 of match or =~ when you only need a true/false answer — it is faster and does not pollute the $~/$1 globals.
  • Prefer named captures ((?<name>...)) over numbered ones once a pattern has more than one or two groups; match[:name] is far more readable than match[3] and survives someone reordering the groups later.
  • Anchor with \A and \z instead 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; use Regexp.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 returns true if str is a valid hex color like "#1a2b3c" or "#FFF" (either 3 or 6 hex digits after the #), and false otherwise. Anchor the pattern so "#1a2b3cXX" is rejected.
  • Given the string "alice@example.com, bob@test.org, not-an-email", use scan with a regex to extract just the two valid-looking email addresses into an array.
  • Write a method that takes a sentence and uses gsub to capitalize the first letter of every word (hint: match \b\w and use a block that calls upcase on the matched letter). For input "the ruby language", the expected output is "The Ruby Language".

Summary

  • Regex literals compile to Regexp objects; Ruby’s Onigmo engine executes them using backtracking.
  • match? gives a fast boolean; match/=~ return a MatchData (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.
  • scan collects every match, sub/gsub replace the first/all matches (optionally via a block using $1), and split divides a string on a pattern.
  • Always escape literal metacharacters like ., and prefer \A/\z over ^/$ for whole-string validation.