Regular Expressions

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

The reference material consulted while preparing these pages is listed in the section bibliography.

This page is written against Ruby 4.0 (4.0.7, checked on 2026-10-18); features newer than Ruby 3.0 name the version that introduced them. A regular expression is a pattern that describes a set of strings. In Ruby it is an object of class Regexp, created by a literal such as /\d+/, and it is used in three ways: to test whether a string has a given shape, to extract the pieces of a string that match, and to transform a string by replacing the parts that match. Ruby’s engine is Onigmo, a fork of Oniguruma that also powers other languages, so most Perl-style syntax works as expected; this page shows the syntax Ruby documents, the objects and methods around it (Regexp, MatchData, and the String methods that accept a pattern), the global variables that hold the last match, and the performance and security properties that matter once a pattern meets untrusted input.

The page is a reference, not a tutorial on regular expressions in general. Strings themselves are in Strings, the encodings that affect matching in Encodings and Unicode, literal syntax such as %r in Literals, case/when in Control Expressions, matching a MatchData with case/in in Pattern Matching, and denial-of-service through patterns in Security. For how other languages treat the same ideas, see Go text processing and Rust strings and text.

Every example on this page was syntax-checked with ruby -wc, run with ruby -w on Ruby 4.0.7 and linted with RuboCop 1.92 using the configuration of the running shelf gem (target Ruby 4.0, double-quoted strings). In the examples, # => comments show the real value of the expression on their line, in the Ruby 3.4+ inspect format. The examples use the names of the shelf library catalogue used throughout this section (Shelf, Isbn, Book, Catalog); each file is self-contained so it can be run on its own.

Overview

Piece What it is

Regexp

A compiled pattern: a source string, option flags, an encoding and an optional timeout. Literals are frozen since Ruby 3.0.

MatchData

The result of a successful match: the matched text, the captured groups, their positions and the strings around the match.

String#match, String#=~, String#match?

The usual way to test or extract; Regexp has the same three methods, and Symbol has them too.

String#scan, sub, gsub, split, slice, partition, index

The methods that accept a pattern to find, replace or cut.

$~, $1

The last match of the current method and thread, set by match, =~, scan, gsub and others (not by match?).

Regexp.timeout, Regexp.linear_time?

The Ruby 3.2 tools against regular-expression denial of service.

Creating regular expressions

Literals, %r and Regexp.new

A regular expression literal is written between slashes, optionally followed by option letters. %r takes any delimiter, which avoids escaping slashes in paths (%r{/books/(\d+)}). Regexp.new (alias Regexp.compile) builds one from a string at run time; the second argument is the options, either an Integer combining Regexp::IGNORECASE, Regexp::EXTENDED and Regexp::MULTILINE, or, since Ruby 3.2, a String of the letters i, m and x. Anything else is rejected: an unknown letter raises ArgumentError, and true means ignore case for compatibility. A third positional argument, accepted by old Ruby versions to select an encoding, is no longer accepted.

The literal form is parsed with the program, so a syntax error in a literal pattern is a SyntaxError at load time, while a bad pattern passed to Regexp.new raises RegexpError when the line runs. A literal is also cheaper: without interpolation it is compiled once per program, not on every evaluation, and since Ruby 3.0 it is frozen. A regexp made with Regexp.new is not frozen.

# frozen_string_literal: true

# Three ways to write a regexp literal, and the constructor.
isbn_pattern = /\A\d{3}-\d-\d{2}-\d{6}-\d\z/
path_pattern = %r{\A/books/(\d+)\z}
title_pattern = Regexp.new("harry potter", Regexp::IGNORECASE)

isbn_pattern.inspect # => "/\\A\\d{3}-\\d-\\d{2}-\\d{6}-\\d\\z/"
path_pattern.inspect # => "/\\A\\/books\\/(\\d+)\\z/"
title_pattern.inspect # => "/harry potter/i"

# Interpolation builds a pattern at run time; escape untrusted text first.
user_input = "C++ (2nd ed.)"
Regexp.escape(user_input) # => "C\\+\\+\\ \\(2nd\\ ed\\.\\)"
Regexp.new(Regexp.escape(user_input)).match?("Learning C++ (2nd ed.)") # => true
/#{Regexp.escape(user_input)}/o.source # => "C\\+\\+\\ \\(2nd\\ ed\\.\\)"

# Regexp.union joins alternatives and escapes strings.
Regexp.union("ruby", "go", /r(u|a)st/i) # => /ruby|go|(?i-mx:r(u|a)st)/
Regexp.union(%w[a.b c+d]) # => /a\.b|c\+d/
Regexp.union # => /(?!)/

# Inspecting a regexp.
re = /colou?r/ix
re.source # => "colou?r"
re.to_s # => "(?ix-m:colou?r)"
re.inspect # => "/colou?r/ix"
re.options # => 3
re.casefold? # => true
re.names # => []
Regexp.try_convert("not a regexp") # => nil
Regexp.try_convert(re) # => /colou?r/ix

# A literal is frozen (Ruby 3.0); Regexp.new is not.
/a/.frozen? # => true
Regexp.new("a").frozen? # => false

Regexp.escape (alias Regexp.quote) turns text into a pattern that matches that text literally: it backslash-escapes every metacharacter (. ? - + * ^ \ | $ ( ) [ ] { }), spaces included. Use it whenever any part of a pattern comes from a variable that is not itself a pattern. Regexp.union accepts strings (escaped for you), regexps (embedded with their own flags) or one array of either, and Regexp.union with no arguments returns /(?!)/, a pattern that never matches. Embedding a Regexp object inside another literal with #{} calls to_s, which wraps it in a group with its own flags, such as (?i-mx:ruby), so the case-insensitivity of the inner pattern does not leak into the outer one.

Source, options and conversion

Regexp#source returns the pattern text, #options returns the flag integer, #casefold? tells whether i is set, #names and #named_captures describe the named groups, and #encoding and #fixed_encoding? describe the encoding. Regexp.try_convert returns its argument if it is a Regexp, calls to_regexp if the object defines it, and otherwise returns nil. Regexp#inspect gives the literal form; Regexp#to_s gives the embeddable (?flags:source) form. Two regexps are == and eql? when their source, options and encoding are equal, and they hash alike; two patterns that merely match the same strings are not equal.

The o modifier and interpolation

A literal with interpolation, /\A#{prefix}/, builds a new Regexp every time it is evaluated. The o modifier makes Ruby build it once, the first time, and reuse that object forever, even if the interpolated value changes later. That is rarely what you want today: assign the pattern to a constant or a local variable instead, which says the same thing without the surprise (see the example under Modes). Interpolation also inserts text, not a pattern object: an interpolated string containing metacharacters changes the meaning of the pattern, which is why untrusted values must go through Regexp.escape.

Matching

There are four ways to ask whether a pattern matches. They differ in what they return and in what they record.

Expression Returns Sets last-match globals Use it for

re.match(str), str.match(re)

MatchData or nil

yes

extracting captures

re =~ str, str =~ re

index of the match, or nil

yes

a position, or named-group local variables

re.match?(str), str.match?(re)

true or false

no

a plain yes/no test (fastest)

re === str

true or false

yes

case/when, grep

match and match? take an optional start position (re.match(str, 12) starts searching at character 12), and match yields the MatchData to a block when it matched and returns the block’s value. =~ with a literal Regexp on the left and named groups also assigns local variables, one per name (see Named groups). The operator =~ between two Strings is a TypeError, and nil =~ re is nil (the generic Object#=~ was removed in Ruby 3.2, so other objects raise NoMethodError). String#match and String#match? convert a String argument to a Regexp, but sub, gsub, split, scan, index and friends take a String argument literally.

# frozen_string_literal: true

line = "2026-10-18 lend 978-0-13-468599-1 by ana"
re = /(\d{4})-(\d\d)-(\d\d)/

# match returns MatchData or nil; =~ returns an index or nil; match? returns a boolean.
re.match(line) # => #<MatchData "2026-10-18" 1:"2026" 2:"10" 3:"18">
re =~ line # => 0
line =~ /by/ # => 34
line.match?(/lend/) # => true
re.match("no date here") # => nil
[/lend/ === line] # => [true]
/lend/.match(line, 12) # => nil
line.match(/\d+/, 5)[0] # => "10"

# match and =~ set the last-match state; match? does not.
line.match(/(?<verb>lend|return)/)
Regexp.last_match(:verb) # => "lend"
Regexp.last_match.pre_match.length # => 11
Regexp.last_match.post_match.length # => 25
"abc".match?(/b/)
Regexp.last_match(0) # => "lend"
"abc".match(/z/)
Regexp.last_match # => nil

Because match? allocates no MatchData and does not touch the globals, prefer it when you only need a boolean (see Performance). Prefer match or =~ when you need the captures, and never rely on a later $1 after a match?.

# frozen_string_literal: true

# String#match and match? convert a String argument to a Regexp; sub, gsub, split, scan, index do not.
"a.c".match?("a.c") # => true
"abc".match?("a.c") # => true
"abc".sub("a.c", "X") # => "abc"
"a.c".sub("a.c", "X") # => "X"
"abc".scan("b") # => ["b"]

# =~ needs a Regexp on one side; two Strings raise TypeError, and nil =~ is nil.
begin
  "abc" =~ "b"
rescue TypeError => e
  e.message # => "type mismatch: String given"
end
(nil =~ /a/) # => nil

# match yields the MatchData to a block when it matched.
"order 66".match(/\d+/) { |m| m[0].to_i * 2 } # => 132
"no digits".match(/\d+/) { |m| m[0] } # => nil

# A Regexp embedded in another keeps its own flags through to_s.
word = /ruby/i
combined = /\A#{word} on rails\z/
combined.inspect # => "/\\A(?i-mx:ruby) on rails\\z/"
combined.match?("RUBY on rails") # => true
combined.match?("RUBY ON RAILS") # => false

# Comparison: equal when source and options are equal; sources that merely behave alike differ.
plain = Regexp.new("a")
same = [plain == /a/, plain.eql?(/a/), plain.hash == /a/.hash]
same.all? # => true
[plain == /a/i, Regexp.new("[ab]") == /a|b/] # => [false, false]

# Regexp#=== makes a regexp usable in case/when and returns false for non-strings.
def classify(value)
  case value
  when /\A\d+\z/ then :digits
  when /\A[[:alpha:]]+\z/ then :letters
  else :other
  end
end

[classify("42"), classify("abc"), classify(42), classify(:abc)] # => [:digits, :letters, :other, :letters]

The last match: $~, $1 and Regexp.last_match

Every successful match, =~, ===, scan, gsub, sub, [], slice, index and the other methods listed in the next sections stores a MatchData in the special variable $~, and a failed match stores nil. The other match variables are views of it:

Variable Value

$~

the MatchData itself (Regexp.last_match, English name $LAST_MATCH_INFO)

$&

the matched text ($MATCH, Regexp.last_match(0))

$`

the text before the match ($PREMATCH, MatchData#pre_match)

$'

the text after the match ($POSTMATCH, MatchData#post_match)

$+

the last group that matched ($LAST_PAREN_MATCH)

$1 to $9 and higher

the numbered groups; unmatched ones are nil. $0 is unrelated: it is the program name

The state is stored in the stack frame of the method that ran the match, not in a true global, and it is also local to the thread. Two consequences follow. A match inside a called method never overwrites the caller’s $~, so a helper cannot break your $1; and a block shares the state of the method that wrote it, so a gsub block can call Regexp.last_match and see the match that is being replaced. A new thread starts with no match.

# frozen_string_literal: true

# The last match ($~, $1, ...) is local to the method and thread that matched.
def parse_year(text)
  text =~ /(\d{4})/
  Regexp.last_match(1)
end

"id 7" =~ /(\d)/
parse_year("year 2026") # => "2026"
Regexp.last_match(1) # => "7"

# A block shares the match state of the method that wrote it.
"a1b2".gsub(/\d/) { Regexp.last_match(0).to_i.succ.to_s } # => "a2b3"
Regexp.last_match(0) # => "2"

# Another thread starts with its own, empty match state.
"zzz" =~ /z/
Thread.new { Regexp.last_match }.value # => nil
Regexp.last_match(0) # => "z"

The numbered variables are read-only views of $~, but $~ itself can be assigned ($~ = nil clears it). Their shortcuts have cryptic names, so the style guide and RuboCop prefer Regexp.last_match (cop Style/PerlBackrefs flags $1 and $&) and the long English aliases from require "English" (cop Style/SpecialGlobalVars flags $~ and $'). The example below keeps the short names to show what they are, and says so with an explicit rubocop:disable pair; code you write for a team should call Regexp.last_match.

# frozen_string_literal: true

require "English"

# The Perl-style globals. RuboCop prefers Regexp.last_match (Style/PerlBackrefs)
# and the English names (Style/SpecialGlobalVars), so this illustration opts out.
# rubocop:disable Style/PerlBackrefs, Style/SpecialGlobalVars
/s(\w{2}).*(c)/.match("haystack")
[$~, $&, $`, $', $+] # => [#<MatchData "stac" 1:"ta" 2:"c">, "stac", "hay", "k", "c"]
[$1, $2] # => ["ta", "c"]

# The English library gives them readable names.
[$LAST_MATCH_INFO.equal?($~), $MATCH, $PREMATCH, $POSTMATCH, $LAST_PAREN_MATCH] # => [true, "stac", "hay", "k", "c"]
# rubocop:enable Style/PerlBackrefs, Style/SpecialGlobalVars

Since Ruby 3.0, Enumerable#grep and grep_v with a Regexp and no block no longer change Regexp.last_match. With a block they still set it for each element, as the grep example later shows.

The syntax of a pattern

Ruby’s syntax is the Onigmo syntax in its Ruby flavour. A pattern is a sequence of atoms (literal characters, classes, groups, escapes), each optionally followed by a quantifier, combined with alternation. Everything except the metacharacters . ? - + * ^ \ | $ ( ) [ ] { } matches itself. A literal regexp behaves like a double-quoted string for interpolation and escapes such as \n, \t, é and \x41, but some backslash sequences have a regexp meaning instead of the string one: in a pattern \s is whitespace, not a space, and \b is a word boundary, not a backspace (inside brackets [\b] it is the backspace). Because the pattern is parsed first by the Ruby lexer, a single-quoted string passed to Regexp.new needs the backslashes to survive: Regexp.new('\d+') and Regexp.new("\\d+") are the same pattern.

Anchors

An anchor matches a position, not a character. Ruby differs from many languages here: ^ and $ always mean the start and end of a line, never of the string, and no option changes that.

Anchor Matches

^ and $

the start and the end of any line (just after, or just before, a newline)

\A

the start of the string

\z

the very end of the string

\Z

the end of the string, or just before one final newline

\b and \B

a word boundary (between \w and \W, or at an edge) and its opposite

\G

where the previous match ended (in scan and gsub), or where the search started (with a start offset in match)

\K

resets the start of the reported match: everything matched before it is not included

Use \A...\z to validate a whole string. Using ^...$ for validation is a classic security mistake: the pattern matches any single line of a multi-line value, so "978-0-13-468599-1\nrm -rf /" passes a ^...$ check for an ISBN. Brakeman reports this mistake in Rails validations and the Ruby style guide warns against it; the fix is always \A and \z (\Z still allows one trailing newline, which is what gets leaves behind).

# frozen_string_literal: true

# ^ and $ match at every line boundary; \A and \z match the whole string.
text = "978-0-13-468599-1\nrm -rf /"
text.match?(/^\d{3}-\d-\d{2}-\d{6}-\d$/) # => true
text.match?(/\A\d{3}-\d-\d{2}-\d{6}-\d\z/) # => false
"978-0-13-468599-1".match?(/\A\d{3}-\d-\d{2}-\d{6}-\d\z/) # => true

# \Z also matches before one final newline; \z does not.
"abc\n".match?(/abc\Z/) # => true
"abc\n".match?(/abc\z/) # => false
"abc\n\n".match?(/abc\Z/) # => false

# Word boundaries.
"scarf in the scan".scan(/\bsca\w*/) # => ["scarf", "scan"]
"scarf".match?(/\Bcar/) # => true
"a-b".gsub(/\b/, "|") # => "|a|-|b|"

# \G anchors a scan to the end of the previous match.
"    a b c".gsub(/ /, "_") # => "____a_b_c"
"    a b c".gsub(/\G /, "_") # => "____a b c"

# The offset argument moves where \G (and the search) starts.
"hello, world".match(/,/, 3) # => #<MatchData ",">
"hello, world".match(/\G,/, 3) # => nil

# \K drops everything matched so far from the result.
"price: 42".slice(/price: \K\d+/) # => "42"
"price: 42".sub(/price: \K\d+/, "99") # => "price: 99"

Character classes

A character class in square brackets matches one character out of a set. Ranges use a hyphen, a leading ^ negates the set, classes nest ([a-z[0-9]]), and && intersects two sets ([a-w&&[^c-g]]). Inside a class most metacharacters are literal, but \, ], ^ (first), - (between two characters) and [ still need care.

Shorthand Matches

.

any character except a newline (any character including newline with the m option)

\w and \W

an ASCII word character [a-zA-Z0-9_] and its complement

\d and \D

an ASCII digit [0-9] and its complement

\h and \H

a hexadecimal digit [0-9a-fA-F] and its complement

\s and \S

ASCII whitespace [ \t\r\n\f\v] and its complement

\R

a line break: \n, \r, \r\n, form feed, vertical tab, and the Unicode line and paragraph separators

\X

an extended grapheme cluster (what a reader would call one character)

The shorthand classes are ASCII only, even on UTF-8 strings. "café" =~ /\w+$/ stops before the é. For Unicode-aware matching use the POSIX bracket expressions ([[:alpha:]], [[:digit:]], [[:alnum:]], [[:upper:]], [[:lower:]], [[:space:]], [[:punct:]], [[:blank:]], [[:cntrl:]], [[:graph:]], [[:print:]], [[:xdigit:]], plus the Ruby additions [[:word:]] and [[:ascii:]]) or the property syntax \p{...} described in Unicode properties.

# frozen_string_literal: true

# Bracket expressions, ranges, negation and intersection.
"Bird Bard Byrd".scan(/B[aeiou]rd/) # => ["Bird", "Bard"]
"a1-b2".scan(/[a-z0-9]/) # => ["a", "1", "b", "2"]
"a1-b2".scan(/[^a-z0-9]/) # => ["-"]
"a-b".scan(/[a\-z]/) # => ["a", "-"]
"abcdefghijklmnopqrstuvwxyz".scan(/[a-w&&[^c-g]]/).join # => "abhijklmnopqrstuvw"

# Shorthand classes are ASCII only; POSIX brackets and \p{} are Unicode aware.
word = "café_42"
word.scan(/\w+/) # => ["caf", "_42"]
word.scan(/[[:alnum:]]+/) # => ["café", "42"]
word.scan(/\p{Alpha}+/) # => ["café"]
word.scan(/\p{^Alpha}+/) # => ["_42"]
word.scan(/\P{Alpha}+/) # => ["_42"]
"٣٤".match?(/\d/) # => false
"٣٤".match?(/[[:digit:]]/) # => true
"٣٤".match?(/\p{Nd}/) # => true

# \h is a hex digit (not the same as \H), \R is any line break, \s is ASCII whitespace.
"ff 0g".scan(/\h+/) # => ["ff", "0"]
"a\r\nb\nc".split(/\R/) # => ["a", "b", "c"]
"a b".match?(/\s/) # => false
"a b".match?(/[[:space:]]/) # => true

# Unicode categories, scripts and the dot.
"Ana Zoë 東京".scan(/\p{Lu}/) # => ["A", "Z"]
"Ana Zoë 東京".scan(/\p{Han}+/) # => ["東京"]
"Ana Zoë 東京".scan(/\p{Latin}+/) # => ["Ana", "Zoë"]
"line\nbreak".match?(/line.break/) # => false
"line\nbreak".match?(/line.break/m) # => true

Quantifiers

A quantifier follows an atom and says how many times it may repeat.

Quantifier Meaning

*, +, ?

zero or more, one or more, zero or one

{n}

exactly n times

{n,}, {,m}, {n,m}

at least n, at most m, between n and m

*?, +?, ??, {n,m}?

lazy (minimal): as few as possible

*+, ++, ?+

possessive: as many as possible and never give back (there is no possessive form of {n,m})

Greedy quantifiers take as much as they can and then give characters back until the rest of the pattern matches (backtracking); lazy ones take as little as they can and grow; possessive ones and atomic groups (?>...) never give back, which can make a match fail where a greedy one succeeds, and makes failure fast. A quantifier applies to the single preceding atom, so repeating several characters needs a group: (?:ab)+ is repeated "ab", ab+ is "a" followed by repeated "b".

# frozen_string_literal: true

# Counted repetition.
"aaaa".match(/a{2}/)[0] # => "aa"
"aaaa".match(/a{2,}/)[0] # => "aaaa"
"aaaa".match(/a{,3}/)[0] # => "aaa"
"aaaa".match(/a{1,2}/)[0] # => "aa"

# Greedy takes as much as possible, lazy as little as possible.
html = "<b>bold</b> and <i>italic</i>"
html.match(/<.+>/)[0] # => "<b>bold</b> and <i>italic</i>"
html.match(/<.+?>/)[0] # => "<b>"
html.scan(/<(\w+)>/).flatten # => ["b", "i"]

# Possessive quantifiers and atomic groups never give characters back.
'"Quote"'.match(/".*"/) # => #<MatchData "\"Quote\"">
'"Quote"'.match(/".*+"/) # => nil
'"Quote"'.match(/"(?>.*)"/) # => nil
'"Quote"'.match(/"[^"]*+"/) # => #<MatchData "\"Quote\"">

# {n,m} has no possessive form; the + after it is a separate quantifier.
"aaa".match(/a{1,2}+/)[0] # => "aaa"

# Quantifiers apply to the last atom, so group to repeat several characters.
"abab".match(/\A(?:ab)+\z/).to_a # => ["abab"]
"abab".match(/\Aab+\z/) # => nil

Groups, captures and backreferences

Parentheses group atoms and capture the text they matched. Captures are numbered left to right by their opening parenthesis, counting from 1; group 0 is the whole match. A group that did not take part in the match (an optional one, or a branch of an alternation) is nil. The variants are:

Syntax Meaning

(...)

capturing group

(?:...)

non-capturing group

(?<name>...) or (?'name'...)

named capturing group

(?>...)

atomic group

(?i), (?-i), (?i:...), (?m), (?x)

switch modes for the rest of the group, or for the subexpression

(?#...)

comment

\1 to \9, \k<name>

backreference: the text the group captured

\g<name>, \g<1>

subexpression call: run the group’s pattern again

\0

the whole match, usable only in a replacement string ('\0'), not inside the pattern

Named groups and backreferences

Once a pattern contains a named group, plain parentheses stop capturing (they behave as (?:...)), so you cannot mix named and numbered captures; RuboCop’s Lint/MixedRegexpCaptureTypes flags the attempt. MatchData#[] takes a Symbol or a String name. When a regexp literal with named groups stands on the left of =~, Ruby assigns each capture to a local variable of that name (and nil if the match failed). That happens only for a literal on the left side: not with match, not with the operands swapped, and not when the pattern is built with interpolation. A name may appear twice (in different alternatives); the reader then sees the one that matched. Names must start with a letter or underscore; a lowercase first letter is conventional because the name becomes a local variable.

# frozen_string_literal: true

# Capturing groups are numbered by their opening parenthesis.
date = /(\d{4})-(\d\d)-(\d\d)/.match("due 2026-10-18")
date.captures # => ["2026", "10", "18"]
date.to_a # => ["2026-10-18", "2026", "10", "18"]

# An optional group that did not take part is nil.
/July 4(th)?/.match("July 4").to_a # => ["July 4", nil]

# (?: ) groups without capturing.
/(?:\d{4})-(\d\d)-(\d\d)/.match("2026-10-18").captures # => ["10", "18"]

# Named groups: (?<name>) or (?'name'). Once a name exists, plain groups stop capturing.
price = /\$(?<dollars>\d+)\.(?'cents'\d+)/.match("$3.67") # => #<MatchData "$3.67" dollars:"3" cents:"67">
price[:dollars] # => "3"
price["cents"] # => "67"
price[2] # => "67"
Regexp.new("\\$(?<dollars>\\d+)\\.(\\d+)").match("$3.67") # => #<MatchData "$3.67" dollars:"3">

# A regexp literal on the left of =~ creates local variables from named groups.
if /(?<year>\d{4})-(?<month>\d\d)/ =~ "released 2025-12"
  [year, month] # => ["2025", "12"]
end

# Backreferences: \1 .. \9, \k<name>, and \0 in a replacement.
/(?<q>["']).*?\k<q>/.match(%(say "hi" and 'bye')) # => #<MatchData "\"hi\"" q:"\"">
/[csh](..) [csh]\1 in/.match("The cat sat in the hat")[0] # => "cat sat in"
"The cat sat".gsub(/[cs]at/, '[\0]') # => "The [cat] [sat]"
"Ana Ruiz".sub(/(?<first>\w+) (?<last>\w+)/, '\k<last>, \k<first>') # => "Ruiz, Ana"

# Interpolation does not create local variables.
pattern = "(?<n>\\d+)"
/#{pattern}/ =~ "42"
[defined?(n)] # => [nil]

# Regexp#names and #named_captures describe the groups; a repeated name lists every index.
/(?<a>.)(?<b>.)/.named_captures # => {"a" => [1], "b" => [2]}
/(?<d>\d)x|y(?<d>\d\d)/.named_captures # => {"d" => [1, 2]}
/(?<d>\d)x|y(?<d>\d\d)/.match("y12")[:d] # => "12"

In replacement strings of sub and gsub, the backslash sequences are \1 to \9, \0 and \& (the whole match), \` (before), \' (after), \k<name> and \\ (a literal backslash). A replacement written in double quotes needs every backslash doubled, so single quotes ('\2/\1') are clearer, and a block avoids the escapes entirely.

Lookaround, absence, conditionals and recursion

Lookaround assertions check text around the current position without consuming it. They take the groups (?=...) (lookahead), (?!...) (negative lookahead), (?<=...) (lookbehind) and (?<!...) (negative lookbehind). A lookbehind must be of fixed width, though its top-level alternatives may differ in length ((?<=a|bc) is accepted, (?<=a+) is a RegexpError).

Ruby adds three constructs found in few other engines:

  • the absence operator (?~pattern) matches any string (including the empty one) that does not contain pattern, which is the clean way to write "everything up to the first closing delimiter";

  • conditionals (?(1)yes|no) or (?(<name>)yes|no) choose a branch depending on whether a group captured;

  • subexpression calls \g<name> run a group’s pattern again at the current position, which makes recursive patterns such as balanced parentheses possible.

# frozen_string_literal: true

# Lookahead and lookbehind are zero-width.
"price 10 EUR, 20 USD".scan(/\d+(?= EUR)/) # => ["10"]
"price 10 EUR, 20 USD".scan(/\d+(?! EUR)\b/) # => ["20"]
"EUR10 USD20".scan(/(?<=EUR)\d+/) # => ["10"]
"EUR10 USD20".scan(/(?<!EUR)\b\d+/) # => []
"EUR10 USD20".scan(/(?<!EUR)\d+/) # => ["0", "20"]

# Lookbehind must be fixed width, though top-level alternatives may differ.
"a1 bc2".scan(/(?<=a|bc)\d/) # => ["1", "2"]
begin
  Regexp.new("(?<=a+)b")
rescue RegexpError => e
  e.message # => "invalid pattern in look-behind: /(?<=a+)b/"
end

# Insert thousands separators with a lookahead that counts remaining digits.
"1234567".gsub(/(?<=\d)(?=(?:\d{3})+\z)/, ",") # => "1,234,567"

# Absence operator (?~...): anything that does not contain the subexpression.
"/* a */ b /* c */".match(%r{/\*(?~\*/)\*/})[0] # => "/* a */"

# Conditionals: (?(1)yes|no) tests whether group 1 took part.
quoted = /\A(")?\w+(?(1)")\z/
['"x"', "x", '"x x"'].map { |s| quoted.match?(s) } # => [true, true, false]

# Subexpression calls: \g<name> re-runs a group, which allows recursion.
balanced = /\A(?<paren>\((?:[^()]|\g<paren>)*\))\z/
["(a(b)c)", "(a(b c)", "()"].map { |s| balanced.match?(s) } # => [true, false, true]

Modes: i, m, x and o

Four letters after the closing slash (or in the options argument of Regexp.new) change how a pattern is read.

Letter Constant Effect

i

Regexp::IGNORECASE (1)

ignore case, including non-ASCII letters (Unicode case folding)

x

Regexp::EXTENDED (2)

extended mode: whitespace in the pattern is ignored and # starts a comment to the end of the line

m

Regexp::MULTILINE (4)

the dot matches a newline (what other languages call dot-all)

o

none

interpolate #{} only once, on first evaluation

n, u, e, s

Regexp::NOENCODING (32) and FIXEDENCODING (16)

select the encoding, see Encodings

The m option is the one that surprises people: in Ruby it affects only the dot, never ^ or $, which are already line anchors. In extended mode a space or # that you want to match must be escaped (\ , \#) or placed in a bracket expression ([ ]). Extended mode with named groups and comments is the way to keep a long pattern readable. The mode letters can also be turned on and off inside the pattern for a part of it, as (?i)te(?-i)st and t(?i:e)st show.

# frozen_string_literal: true

# i: case-insensitive. Ruby's m is "dot matches newline", not "multi-line anchors".
/ruby/i.match?("RUBY") # => true
/a.c/.match?("a\nc") # => false
/a.c/m.match?("a\nc") # => true
/^b/m.match?("a\nb") # => true
/\Ab/m.match?("a\nb") # => false

# x: whitespace and # comments are ignored, so a pattern can be documented.
ISBN13 = /
  \A
  (?<prefix>97[89])  # GS1 prefix
  -?(?<group>\d{1,5})  # registration group
  -?(?<publisher>\d{1,7})
  -?(?<title>\d{1,6})
  -?(?<check>\d)     # check digit
  \z
/x
parts = ISBN13.match("978-0-13-468599-1").named_captures
parts.values_at("prefix", "publisher", "check") # => ["978", "13", "1"]
/a b/x.match?("ab") # => true
/a\ b/x.match?("a b") # => true
/a[ ]b/x.match?("a b") # => true

# Modes can be switched inside a pattern, for the rest of the group or for a sub-group.
/(?i)te(?-i)st/.match?("TEst") # => true
/(?i)te(?-i)st/.match?("TEST") # => false
/t(?i:e)st/.match?("tEst") # => true
/(?m:a.b)c.d/.match?("a\nbc\nd") # => false

# Flags as integers and as a string (Ruby 3.2); Regexp#options reports the integer.
Regexp.new("a", Regexp::IGNORECASE | Regexp::MULTILINE).options # => 5
Regexp.new("a", "ix").inspect # => "/a/ix"
[Regexp::IGNORECASE, Regexp::EXTENDED, Regexp::MULTILINE] # => [1, 2, 4]
Regexp.new("a", true).inspect # => "/a/i"

# The o flag interpolates once; the value at the first evaluation sticks.
def build(word) = /#{word}/o
[build("cat").source, build("dog").source] # => ["cat", "cat"]

Unicode and encodings

Unicode properties

\p{Name} matches a character that has a Unicode property, \P{Name} and \p{^Name} match one that does not. Names are case-insensitive, and a space, hyphen or underscore are equivalent. Ruby 4.0 uses Unicode 17.0 and Emoji 17.0 (Ruby 3.2 used 15.0). The families are:

Family Examples

General categories (long or short names)

\p{L} or \p{Letter}, \p{Lu} uppercase letter, \p{Ll} lowercase, \p{N} and \p{Nd} numbers and decimal digits, \p{P} punctuation, \p{S} symbols, \p{Z} separators, \p{M} marks, \p{C} other

Scripts

\p{Latin}, \p{Greek}, \p{Han}, \p{Hiragana}, \p{Cyrillic}, \p{Arabic}

Blocks

\p{In_Basic_Latin}, \p{In_Greek_and_Coptic} (an In_ prefix)

POSIX-like names

\p{Alpha}, \p{Alnum}, \p{Upper}, \p{Lower}, \p{Space}, \p{Punct}, \p{Digit}, \p{XDigit}, \p{Word}, \p{ASCII}

Other properties

\p{Emoji}, \p{Any}, \p{Assigned}, \p{Graph}, \p{Print}

An unknown property name is an error at parse time (invalid character property name). The complete list is on the Ruby documentation’s unicode properties page. Property syntax is the portable way to say "a letter in any language" and "a decimal digit in any script", which \w and \d deliberately do not.

Encodings

A regexp has an encoding. A pattern of only US-ASCII characters is US-ASCII and can match a string in any ASCII-compatible encoding; one that contains a non-ASCII character takes the source encoding (UTF-8 for a standard file) and is then fixed: it can match only ASCII-only strings or strings of its own encoding, otherwise Encoding::CompatibilityError is raised. The letters n (no encoding: binary when the pattern has bytes above 127), u (UTF-8), e (EUC-JP) and s (Windows-31J) force one, and Regexp::FIXEDENCODING does the same through Regexp.new. Matching a string with an invalid byte sequence raises ArgumentError; String#scrub or String#valid_encoding? is the way to handle untrusted input before matching.

# frozen_string_literal: true

# Encodings of regexps.
/abc/.encoding # => #<Encoding:US-ASCII>
/é/.encoding # => #<Encoding:UTF-8>
/abc/u.encoding # => #<Encoding:UTF-8>
/abc\xff/n.encoding # => #<Encoding:BINARY (ASCII-8BIT)>
/abc/.fixed_encoding? # => false
/é/.fixed_encoding? # => true

# A fixed-encoding regexp only matches ASCII-compatible strings of its own encoding.
latin = "caf\xE9".dup.force_encoding("ISO-8859-1")
begin
  /é/.match?(latin)
rescue Encoding::CompatibilityError => e
  e.message # => "incompatible encoding regexp match (UTF-8 regexp with ISO-8859-1 string)"
end

# Invalid byte sequences raise ArgumentError on a match.
begin
  "abc\xff".match?(/b/)
rescue ArgumentError => e
  e.message # => "invalid byte sequence in UTF-8"
end
"abc\xff".scrub.match?(/b/) # => true

# Invalid patterns raise RegexpError when built from strings (and SyntaxError for literals).
begin
  Regexp.new("(unclosed")
rescue RegexpError => e
  e.message # => "end pattern with unmatched parenthesis: /(unclosed/"
end
begin
  Regexp.new("[z-a]")
rescue RegexpError => e
  e.message # => "empty range in char class: /[z-a]/"
end
begin
  Regexp.new("a", "q")
rescue ArgumentError => e
  e.message # => "unknown regexp option: q"
end

Methods that accept a Regexp

Besides Regexp and MatchData themselves, the reference documentation lists these core methods as taking a pattern:

Receiver Methods

String

=~, [], []=, byteindex, byterindex, gsub, gsub!, index, match, match?, partition, rindex, rpartition, scan, slice, slice!, split, start_with?, sub, sub!

Symbol

=~, [], match, match?, slice, start_with?

Enumerable

all?, any?, grep, grep_v, none?, one?, slice_after, slice_before, and case equality through ===

scan returns every match: an Array of strings when the pattern has no groups, an Array of capture Arrays when it has, and with a block it yields each and returns the receiver. sub replaces the first match and gsub all of them. The replacement can be a string with backslash references, a Hash (the match text is the key; a missing key gives an empty string), or a block that receives the matched text and can read the captures with Regexp.last_match. split accepts a pattern as the separator; captures are included in the result, a limit of -1 keeps trailing empty fields, and an empty pattern splits into characters. slice/[] accept a pattern and an optional group index or name; partition/rpartition split into the text before, the match and the text after. start_with? matches only at the start and sets the last match. Enumerable#grep takes a pattern (anything with ===) and an optional block that maps the matches, and case/when uses === too.

# frozen_string_literal: true

log = "lend 978-0-13-468599-1 ana\nreturn 978-0-13-468599-1 ana\nlend 978-0-596-51774-8 bob"

# scan: all matches; with groups it returns arrays of captures.
log.scan(/^(\w+) (\S+) (\w+)$/).first # => ["lend", "978-0-13-468599-1", "ana"]
log.scan(/\b(?:lend|return)\b/) # => ["lend", "return", "lend"]
log.scan(/\d{3}-\d-\d{2}-\d{6}-\d/).uniq.size # => 1
lent = []
log.scan(/^lend (\S+)/) { |(isbn)| lent << isbn.delete("-") }
lent.join(",") # => "9780134685991,9780596517748"

# sub / gsub: strings, hashes and blocks as replacements.
"ana ruiz".sub(/\w+/, &:capitalize) # => "Ana ruiz"
"ana ruiz".gsub(/\b(\w)/) { Regexp.last_match(1).upcase } # => "Ana Ruiz"
"a-b_c".gsub(/[-_]/, "-" => "+", "_" => "=") # => "a+b=c"
"2026-10-18".gsub(/(\d+)-(\d+)-(\d+)/, '\3/\2/\1') # => "18/10/2026"
"2026-10-18".gsub(/(?<y>\d+)-(?<m>\d+)-(?<d>\d+)/, '\k<d>.\k<m>.\k<y>') # => "18.10.2026"
"a.b".gsub(".", "-") # => "a-b"
"a\\b".gsub("\\", "\\\\\\\\") # => "a\\\\b"
"a\\b".gsub("\\") { "\\\\" } # => "a\\\\b"

# split with a regexp; captures are kept; limit and the empty pattern.
"a, b;c  d".split(/[,;\s]+/) # => ["a", "b", "c", "d"]
"a1b22c".split(/(\d+)/) # => ["a", "1", "b", "22", "c"]
"a1b".split(/(?<=\d)|(?=\d)/) # => ["a", "1", "b"]
"a, b,,".split(/,\s*/) # => ["a", "b"]
"a, b,,".split(/,\s*/, -1) # => ["a", "b", "", ""]

# Other String methods that take a regexp.
"ISBN 9780134685991".slice(/\d{13}/) # => "9780134685991"
"ISBN 9780134685991"[/(?<n>\d{13})/, "n"] # => "9780134685991"
"ISBN 9780134685991"[/ISBN (\d)/, 1] # => "9"
"key = value".partition(/\s*=\s*/) # => ["key", " = ", "value"]
"archive.tar.gz".rpartition(/\.[a-z]+\z/) # => ["archive.tar", ".gz", ""]
"hello world".index(/o/) # => 4
"hello world".rindex(/o/) # => 7
"hello".start_with?(/h.l/) # => true
:symbol.match?(/sym/) # => true
:symbol[/b.l/] # => "bol"

# Enumerable#grep, case/when and slice_before use ===.
%w[ruby go rust].grep(/\Aru/) # => ["ruby", "rust"]
%w[ruby go rust].grep_v(/\Aru/) # => ["go"]
%w[ruby go rust].grep(/\Aru(.)/) { Regexp.last_match(1) } # => ["b", "s"]
%w[ruby go rust].any?(/^g/) # => true

RuboCop nudges toward the simplest tool: Style/RedundantRegexpArgument flags a regexp where a plain string argument does the same (split(/,/) becomes split(",")), Performance/RegexpMatch (from the rubocop-performance extension) flags =~ and match where only a boolean is used and suggests match?, and Style/StringChars replaces split(//) with chars.

MatchData

A successful match returns a MatchData, a snapshot independent of the later last-match state. Its methods:

Method Returns

[], values_at

a group by index, name, Symbol or range; values_at several at once

to_a, captures

all groups including the whole match, or only the numbered ones

names, named_captures

the group names; a Hash of name to text (symbolize_names: true since Ruby 3.3)

pre_match, post_match

the text before and after the match

begin, end, offset

character positions of a group; byteoffset (3.2), bytebegin and byteend (3.4) give byte positions

match, match_length

text and length of a group, nil-safe (3.1)

string, regexp, size

the original string (frozen), the pattern, the number of groups plus one

deconstruct, deconstruct_keys

the captures as Array and as symbol-keyed Hash, for case/in (3.2)

==, eql?, hash

equal when the same pattern matched the same string at the same place

Positions are in characters, and for multibyte strings differ from byte offsets, which is why the byte-based methods exist (they are what String#byteslice expects). MatchData#string is a frozen copy, so mutating the source string after the match does not change the match.

# frozen_string_literal: true

md = /(?<year>\d{4})-(?<month>\d\d)-(?<day>\d\d)/.match("due 2026-10-18, extended")

md.to_a # => ["2026-10-18", "2026", "10", "18"]
md.captures # => ["2026", "10", "18"]
md.names # => ["year", "month", "day"]
md.named_captures # => {"year" => "2026", "month" => "10", "day" => "18"}
md.named_captures(symbolize_names: true) # => {year: "2026", month: "10", day: "18"}
md[0] # => "2026-10-18"
md[:month] # => "10"
md["day"] # => "18"
md[1..2] # => ["2026", "10"]
md.values_at(:year, :day) # => ["2026", "18"]
md.size # => 4
md.string # => "due 2026-10-18, extended"
md.regexp.source # => "(?<year>\\d{4})-(?<month>\\d\\d)-(?<day>\\d\\d)"
md.pre_match # => "due "
md.post_match # => ", extended"

# Positions: character offsets, byte offsets (3.2), and byte begin/end (3.4).
md.begin(0) # => 4
md.end(:year) # => 8
md.offset(:month) # => [9, 11]
md.byteoffset(:day) # => [12, 14]
md.bytebegin(0) # => 4
md.byteend(0) # => 14

# match and match_length (3.1) return the group text or its length.
md.match(:year) # => "2026"
md.match_length(:year) # => 4
md.match_length(:day) # => 2

# Offsets count characters, not bytes, in multibyte strings.
accent = /é/.match("café au lait")
[accent.begin(0), accent.byteoffset(0)] # => [3, [3, 5]]

# MatchData supports pattern matching (3.2): deconstruct gives the captures, deconstruct_keys the named ones.
def period(match)
  case match
  in { year:, month: } then "#{month}/#{year}"
  end
end

def parts(match)
  case match
  in [year, month, day] then [day, month, year]
  end
end

period(md) # => "10/2026"
parts(md) # => ["18", "10", "2026"]
md in { year: "2026" } # => true

Performance

A regexp match is fast, but three habits make a visible difference in hot paths:

  • use match? when you need a boolean, and start_with? or include? with a String when you only need a prefix or a substring: they skip the MatchData and the global bookkeeping;

  • build a pattern once: a literal without interpolation is compiled once, but /\A#{prefix}/ and Regexp.new run the compiler on every evaluation, so hoist such patterns into a constant or a local variable (or Regexp.union the words once);

  • anchor patterns and make the failing case fail fast: an unanchored pattern tries every start position, and a leading .* forces backtracking at each one.

# frozen_string_literal: true

ISBN_SHAPE = /\A\d{3}-\d-\d{2}-\d{6}-\d\z/
SAMPLE = "978-0-13-468599-1"
prefix = "978"
fixed = /\A#{prefix}/

# Objects allocated while the block runs.
def allocations
  before = GC.stat(:total_allocated_objects)
  yield
  GC.stat(:total_allocated_objects) - before
end

counts = {
  match?: allocations { 1000.times { ISBN_SHAPE.match?(SAMPLE) } },
  match: allocations { 1000.times { ISBN_SHAPE.match(SAMPLE) } },
  interpolated: allocations { 1000.times { /\A#{prefix}/ } },
  prebuilt: allocations { 1000.times { fixed.match?(SAMPLE) } }
}

# match? allocates nothing per call; match builds a MatchData each time;
# a literal with interpolation builds a new Regexp (and String) on every evaluation.
counts.transform_values { |n| n >= 1000 } # => {match?: false, match: true, interpolated: true, prebuilt: false}

Safety: backtracking, ReDoS and timeouts

Until Ruby 3.1 the matcher was a plain backtracking engine, so a pattern with nested repetition such as (a+)+$ could take exponential time on a hostile input, and a service that matched user text against such a pattern could be taken down by one request (regular expression denial of service, ReDoS). Ruby 3.2 added two defences, and 3.3 widened the first.

  • Memoization. The matcher caches the states it has already explored and does not retry them, so matching time grows linearly with the input for most patterns (Ruby 3.2). Regexp.linear_time? reports whether a pattern qualifies. Patterns with backreferences and subexpression calls do not; lookarounds and atomic groups qualify since Ruby 3.3. The method is a statement about the interpreter build, not about the pattern text, and it explicitly gives no compatibility promise. The cache uses memory proportional to the input, so an untrusted source can still cost memory.

  • Timeouts. Regexp.timeout = seconds sets a global limit and Regexp.new(source, timeout: seconds) a per-pattern one (Ruby 3.2); an instance timeout overrides the class one, and nil means no limit. A match that exceeds it raises Regexp::TimeoutError, a subclass of RegexpError. Set a class-wide timeout at program start in any service that handles untrusted text, and also never build a pattern from untrusted text without Regexp.escape.

# frozen_string_literal: true

# Ruby 3.2 memoizes backtracking, so most patterns match in time linear in the input.
Regexp.linear_time?(/\A(\w+)\z/) # => true
Regexp.linear_time?("(a+)+$") # => true
# Lookarounds and atomic groups joined the optimization in Ruby 3.3.
Regexp.linear_time?(/(?<=a)b/) # => true
Regexp.linear_time?(/(?>a+)b/) # => true

# Backreferences and subexpression calls still disable it.
Regexp.linear_time?(/\A(a+)+\1\z/) # => false
Regexp.linear_time?(/(?<n>a)\g<n>/) # => false

# A per-regexp timeout (seconds, Float) stops a catastrophic match.
guarded = Regexp.new("\\A(a+)+\\1\\z", timeout: 0.05)
guarded.timeout # => 0.05
begin
  guarded.match?("#{'a' * 30}b")
rescue Regexp::TimeoutError => e
  [e.class, e.class.superclass, e.message] # => [Regexp::TimeoutError, RegexpError, "regexp match timeout"]
end

# The class-wide default applies to every regexp whose own timeout is nil.
Regexp.timeout # => nil
Regexp.timeout = 1.0
Regexp.timeout # => 1.0
/x/.timeout # => nil
Regexp.timeout = nil

The Timeout module’s Timeout.timeout is not a substitute: it interrupts the thread with an asynchronous exception, which has the caveats described in Security and Threads and the GVL, while Regexp.timeout stops the match inside the engine.

Warnings and errors from patterns

With ruby -w the parser warns about patterns that are valid but suspicious: a duplicate range inside a character class ([aa], [a-ca-c]) and a redundant nested repeat operator (a**). Invalid patterns are errors: RegexpError for Regexp.new, SyntaxError for a literal, naming the problem and the pattern. Typical messages are end pattern with unmatched parenthesis, empty range in char class, invalid pattern in look-behind, unmatched range specifier in char-class (a class shorthand used as a range end, as in [\d-z]), and invalid backref number/name.

# frozen_string_literal: true

# Run with ruby -w: both warnings are printed to standard error.
Regexp.new("[aa]")
Regexp.new("a**")
begin
  Regexp.new("[\\d-z]")
rescue RegexpError => e
  puts e.message
end

Running it with ruby -w w2.rb prints, on standard error and standard output:

w2.rb:4: warning: character class has duplicated range: /[aa]/
w2.rb:5: warning: regular expression has redundant nested repeat operator '*': /a**/
unmatched range specifier in char-class: /[\d-z]/

Using regular expressions in the shelf library

The following file parses the lines of a loan log with an extended-mode pattern and named groups, wraps the result in a Data value object, builds a safe search from words typed by a user, and shows that any object with === can take part in case/when like a regexp does.

# frozen_string_literal: true

module Shelf
  # Parses lines of the loan log, for example:
  #   2026-10-18T09:30:00Z lend 978-0-13-468599-1 ana
  class LogLine
    PATTERN = /
      \A
      (?<at>\d{4}-\d\d-\d\dT\d\d:\d\d:\d\dZ)\s+   # ISO 8601 timestamp
      (?<action>lend|return)\s+
      (?<isbn>[\d-]{13,17})\s+                     # digits and hyphens
      (?<member>[[:alpha:]][[:alnum:]_.-]*)        # a member name
      \z
    /x

    Entry = Data.define(:at, :action, :isbn, :member)

    # A callable with === works in case/when and grep, like a regexp does.
    IsbnLike = ->(text) { text.to_s.delete("-").match?(/\A\d{13}\z/) }

    def self.parse(line)
      match = PATTERN.match(line.strip) or raise ArgumentError, "unparsable log line: #{line.inspect}"
      Entry.new(**match.named_captures(symbolize_names: true))
    end
  end
end

entry = Shelf::LogLine.parse("2026-10-18T09:30:00Z lend 978-0-13-468599-1 ana\n")
entry.action # => "lend"
entry.member # => "ana"
entry.to_h.values_at(:at, :isbn) # => ["2026-10-18T09:30:00Z", "978-0-13-468599-1"]

begin
  Shelf::LogLine.parse("garbage")
rescue ArgumentError => e
  e.message # => "unparsable log line: \"garbage\""
end

# Searching a catalogue safely: escape the user's words, then join them.
words = ["c++", "(2nd)"]
Regexp.union(words).source # => "c\\+\\+|\\(2nd\\)"
query = Regexp.union(words.map { |w| /#{Regexp.escape(w)}/i })
titles = ["Learning C++ (2nd ed.)", "Learning Ruby", "Effective C++"]
titles.grep(query) # => ["Learning C++ (2nd ed.)", "Effective C++"]

def kind(text)
  case text
  when Shelf::LogLine::IsbnLike then "looks like an ISBN"
  else "something else"
  end
end

kind("978-0-13-468599-1") # => "looks like an ISBN"
kind("Learning Ruby") # => "something else"

Pitfalls checklist

Pitfall Do this instead

validating with ^...$

anchor with \A...\z; ^ and $ are line anchors

trusting \w, \d, \s for non-ASCII text

use [[:alpha:]], \p{L}, [[:digit:]] or [[:space:]]

thinking m makes ^ and $ multi-line

m is dot-all; anchors are line anchors without any option

using $1 after match?

match? does not set it; use match and read the MatchData

using $1 after another match in between

capture into a variable immediately, or use match and a named group

interpolating untrusted text into a pattern

Regexp.escape (or Regexp.union) the text first

/#{value}/o and a changing value

build once with a constant or variable; o freezes the first value

unanchored or nested-quantifier patterns on untrusted input

anchor, simplify, and set Regexp.timeout

mixing named and numbered groups

use only named groups, or only numbered ones

expecting local variables from match or an interpolated pattern

only a literal on the left of =~ creates them

"a.c".sub("a.c", ...) and "abc".match?("a.c") behave differently

sub, gsub, scan, split, index take a String literally; match and match? build a pattern from it

double-quoted replacement "\1"

the string escape turns it into a control character; write '\1' or "\\1", or use a block

Regexp.new('\d') versus "\d"

in a double-quoted string "\d" is just d; keep patterns as literals when you can

str.match(re)[1] when the pattern may not match

a failed match is nil, so this raises NoMethodError; test with match?, use \&. or pattern matching

Where older books and code are outdated

Books and articles on Ruby regular expressions often predate the current engine, and the two books consulted for this section give the subject only a few pages. Treat the following as the current position:

Older statement Current Ruby (4.0)

Regular expressions can always hang on pathological input and nothing can be done

Ruby 3.2 memoizes most patterns into linear time, adds Regexp.linear_time? and Regexp.timeout; the cache covers lookarounds and atomic groups since 3.3

Regexp.new(pattern, true) or a third argument for the encoding

pass Regexp::IGNORECASE or the string "i" (3.2); a third positional argument is rejected

Object#=~ returning nil for any object

removed in Ruby 3.2; only String, Symbol and Regexp (and nil) define =~

/regexp/ is a mutable object

literals are frozen since Ruby 3.0

$1 and $~ are real globals shared by the whole program

they are local to the method frame and thread

grep clobbers $~

not without a block, since Ruby 3.0

Unicode behaviour of \p{...} as of Ruby 1.9 or 2.x

Unicode 17.0 and Emoji 17.0 in Ruby 4.0

named_captures returns string keys only

symbolize_names: true since 3.3

MatchData is only an indexable object

it supports deconstruct and deconstruct_keys for case/in (3.2)

Version notes

Version Change

3.0

Regexp literals are frozen; grep and grep_v without a block leave Regexp.last_match unchanged.

3.1

MatchData#match and #match_length added.

3.2

Cache-based linear-time matching, Regexp.linear_time?, Regexp.timeout=, the timeout: keyword of Regexp.new, flags as a String, MatchData#byteoffset, #deconstruct, #deconstruct_keys, Unicode 15.0; the generic Object#=~ removed.

3.3

The linear-time cache also handles lookarounds and atomic groups; MatchData#named_captures(symbolize_names:).

3.4

MatchData#bytebegin and #byteend.

4.0

Unicode 17.0 and Emoji 17.0 for property classes.

4.1 (preview, not released as of 2026-10-18)

The development NEWS (the master documentation) lists two changes: all Regexp instances are frozen, not just literals (subclasses are not), and MatchData#integer_at is added. Neither is in a release, so this page does not rely on them.

References