Regex #

A regular expression is a mini-language for describing text patterns — and Ruby integrates it deeply into the language itself, not just as an external library. Regex can be used directly with the =~ operator, in case/when, as an argument to gsub, scan, split, and dozens of other String methods. What makes Ruby stand out is how easy it is to extract data from structured text: named capture groups (?<name>pattern) become local variables directly with match, and the global variables $1, $2, $~ store match results accessible at any time. This article covers regex from the fundamentals to ready-to-use patterns for validating real-world data in Indonesian applications.

Basic Syntax and Creating Regex #

There are two ways to create a regex in Ruby:

# 1. Literal — the most common way
pattern = /hello/
email_pattern = /\A[\w+\-.]+@[a-z\d\-.]+\.[a-z]+\z/i

# 2. Regexp.new — for dynamic patterns from variables
word = "ruby"
dynamic_pattern = Regexp.new(word)
dynamic_pattern = Regexp.new(word, Regexp::IGNORECASE)   # with a flag

# When to use Regexp.new:
search_term = gets.chomp   # user input
pattern = Regexp.new(Regexp.escape(search_term))   # escape special characters!
# Regexp.escape("a.b*c") => "a\\.b\\*c"  (dot and star are escaped)

Flags / Modifiers #

Flags come after the closing / delimiter and change matching behavior:

text = "Ruby is a LANGUAGE that is Fun"

# i — case insensitive (ignore uppercase/lowercase)
puts text.match?(/ruby/i)      # => true  (without i: false)
puts text.match?(/language/i)  # => true

# m — multiline — the dot (.) matches newlines
multi = "first line\nsecond line"
puts multi.match?(/first.second/)     # => false  (. doesn't match \n)
puts multi.match?(/first.second/m)    # => true   (with m: matches)

# x — extended — allow whitespace and comments in the pattern
email_pattern = /
  \A                # start of string
  [\w+\-.]+         # username (letters, digits, +, -, .)
  @                 # @ symbol
  [a-z\d\-.]+       # domain
  \.                # dot
  [a-z]+            # TLD
  \z                # end of string
/ix   # i=case insensitive, x=extended

puts "[email protected]".match?(email_pattern)   # => true

# Combining flags
puts "HELLO\nWORLD".match?(/hello.world/im)   # => true (i + m)

Special Characters and Shorthand #

Regex uses special characters to describe character classes:

# Literal characters vs special characters
# . ^ $ * + ? { } [ ] \ | ( )  ← special characters, need escaping with \

# Character shorthand
# \d  — digit (0-9), equivalent to [0-9]
# \D  — not a digit, equivalent to [^0-9]
# \w  — word character (letters, digits, underscore), equivalent to [a-zA-Z0-9_]
# \W  — not a word character
# \s  — whitespace (space, tab, newline)
# \S  — not whitespace
# .   — any character except newline (with the m flag: including newline)

text = "Price: Rp 15.000 (10% discount)"

puts text.scan(/\d+/)         # => ["15", "000", "10"]
puts text.scan(/\d[\d.]+/)    # => ["15.000", "10"]  (numbers with dots)
puts text.scan(/\w+/)         # => ["Price", "Rp", "15", "000", "10", "discount"]
puts text.scan(/\s+/).length  # => number of whitespace groups

# Character class — custom definitions
/[aeiou]/      # only lowercase vowels
/[a-z]/        # lowercase letters a to z
/[A-Za-z]/     # all letters
/[0-9a-f]/     # hexadecimal
/[^aeiou]/     # not vowels (^ inside [] means negation)
/[.,;:!?]/     # specific punctuation

# Escaping special characters to match literally
/Rp\s\d+/      # "Rp " followed by one or more digits
/www\.ruby-lang\.org/   # dot as a literal dot

Quantifiers — Repetition Counts #

Quantifiers determine how many times the preceding pattern must appear:

# * — 0 or more
# + — 1 or more
# ? — 0 or 1 (optional)
# {n} — exactly n times
# {n,} — at least n times
# {n,m} — between n and m times

text = "aaabbc"
puts text.match(/a+/)[0]      # => "aaa"
puts text.match(/b*/)[0]      # => "" (0 or more b at the start)
puts text.match(/c?/)[0]      # => ""

# Practical examples
/\d{4}/              # exactly 4 digits — a year
/\d{2,4}/            # 2 to 4 digits
/\+?62\d{9,12}/      # Indonesian phone number (with or without +62)
/[A-Z]{2,3}/         # 2-3 uppercase country code

# Greedy vs Lazy quantifiers
text = "<b>bold</b> and <i>italic</i>"

puts text.match(/<.+>/)[0]    # => "<b>bold</b> and <i>italic</i>"  (greedy!)
puts text.match(/<.+?>/)[0]   # => "<b>"  (lazy — as short as possible)
puts text.scan(/<.+?>/)       # => ["<b>", "</b>", "<i>", "</i>"]
Quantifiers are greedy by default — they try to match as many characters as possible. Add ? after a quantifier (+?, *?, ??) to make it lazy — matching as few as possible. Choosing wrongly between greedy and lazy is a common source of regex bugs.

Anchors — Positions in the String #

Anchors don’t match characters, but positions where characters sit:

# \A — start of the string (the whole string, not just a line)
# \z — end of the string
# ^  — start of a line (every line in multiline)
# $  — end of a line
# \b — word boundary
# \B — not a word boundary

text = "ruby on rails\nruby is great"

# \A and \z — the whole string
puts text.match?(/\Aruby/)    # => true  (the string starts with "ruby")
puts text.match?(/\Agreat/)   # => false
puts text.match?(/great\z/)   # => true  (the string ends with "great")

# ^ and $ — per line (with the multiline flag)
puts text.scan(/^ruby/)       # => ["ruby", "ruby"]  (two lines start with "ruby")
puts text.scan(/rails$/)      # => ["rails"]

# \b — word boundary
text2 = "cat concatenate category"
puts text2.scan(/\bcat\b/)    # => ["cat"]   (only the exact word "cat")
puts text2.scan(/cat/)        # => ["cat", "cat", "cat"]  (everything containing "cat")

# \A...\z — full format validation (REQUIRED for input validation!)
"abc123".match?(/\A[a-z\d]+\z/)    # => true  (only lowercase letters and digits)
"abc 123".match?(/\A[a-z\d]+\z/)   # => false (there's a space)
The difference between \A/\z and ^/$:
  \A  → start of the entire string (unaffected by newlines)
  \z  → end of the entire string
  ^   → start of every line (in a multiline string)
  $   → end of every line

For input validation: ALWAYS use \A and \z, not ^ and $
A regex /^evil$/ can be bypassed with "good\nevil" because ^ matches
the start of a line, not the start of the string!

Capturing Groups #

Groups allow extracting specific parts of a matched string:

# Numbered capture groups — accessed with [1], [2], etc. or $1, $2
pattern = /(\d{4})-(\d{2})-(\d{2})/
date = "Born: 1990-03-15"

if m = pattern.match(date)
  puts m[0]   # => "1990-03-15"  (the entire match)
  puts m[1]   # => "1990"        (group 1)
  puts m[2]   # => "03"          (group 2)
  puts m[3]   # => "15"          (group 3)
  puts "Year: #{m[1]}, Month: #{m[2]}, Day: #{m[3]}"
end

# Global variables $1, $2 — available after a successful match
"price: 15000" =~ /(\d+)/
puts $1    # => "15000"
puts $~[0] # => "15000"  ($~ is the last MatchData)
puts $&    # => "15000"  (the matched string)
puts $`    # => "price: "  (the string before the match)
puts $'    # => ""  (the string after the match)

Named Capture Groups #

Named capture groups are a far more expressive and readable way:

# (?<name>pattern) — named capture group
date_pattern = /(?<year>\d{4})-(?<month>\d{2})-(?<day>\d{2})/

if m = date_pattern.match("1990-03-15")
  puts m[:year]   # => "1990"
  puts m[:month]  # => "03"
  puts m[:day]    # => "15"
end

# Named captures become local variables automatically with =~
log_pattern = /\[(?<level>\w+)\] (?<message>.+)/
"[ERROR] Database connection failed" =~ log_pattern
puts level   # => "ERROR"   (becomes a local variable directly!)
puts message # => "Database connection failed"

# Real-world example: parsing logs with named captures
access_pattern = /
  (?<ip>[\d.]+)           # IP address
  \s-\s-\s
  \[(?<time>[^\]]+)\]     # timestamp in [ ]
  \s"(?<method>\w+)       # HTTP method
  \s(?<path>[^\s"]+)      # URL path
/x

log = '192.168.1.1 - - [15/Aug/2024:14:30:05] "GET /api/users'
if m = access_pattern.match(log)
  puts "IP: #{m[:ip]}, Method: #{m[:method]}, Path: #{m[:path]}"
end

Non-Capturing Groups #

(?:pattern) creates a group for grouping or alternation without capturing its result:

# (?:pattern) — group without capture
pattern = /(?:Mr|Mrs|Dr)\. (\w+)/
if m = pattern.match("Dr. Santoso")
  puts m[1]   # => "Santoso"  (not "Dr." because (?:) isn't captured)
  puts m[0]   # => "Dr. Santoso"
end

# Alternation within a group
/(?:png|jpg|jpeg|gif|webp)/   # image formats
/(?:https?|ftp):\/\//         # URL protocols

Lookahead and Lookbehind #

Lookahead and lookbehind are zero-width assertions — they check context without consuming characters:

# Positive lookahead (?=pattern) — matches if FOLLOWED by the pattern
# Negative lookahead (?!pattern) — matches if NOT followed by the pattern
# Positive lookbehind (?<=pattern) — matches if PRECEDED by the pattern
# Negative lookbehind (?<!pattern) — matches if NOT preceded by the pattern

text = "price: Rp 15000, discount: Rp 2000"

# Take numbers preceded by "Rp "
numbers = text.scan(/(?<=Rp )\d+/)
puts numbers.inspect   # => ["15000", "2000"]

# Take words followed by a colon
labels = text.scan(/\w+(?=:)/)
puts labels.inspect   # => ["price", "discount"]

# Validate passwords containing both uppercase letters and digits
def strong_password?(pwd)
  pwd.match?(/\A(?=.*[A-Z])(?=.*\d).{8,}\z/)
end

puts strong_password?("secret")        # => false (no uppercase or digits)
puts strong_password?("Secret1")       # => true
puts strong_password?("secret1")       # => false (no uppercase)
puts strong_password?("SECRET")        # => false (no digits)
puts strong_password?("S1")            # => false (fewer than 8 characters)

Main Methods That Use Regex #

match and match? #

text = "Number: 08123456789"
pattern = /0\d{9,11}/

# match — returns MatchData or nil
result = text.match(pattern)
puts result&.[](0)   # => "08123456789"
puts result.nil?     # => false

# match? — returns a boolean only (faster, doesn't allocate MatchData)
puts text.match?(pattern)   # => true

# More idiomatic than =~
if text.match?(pattern)
  puts "Phone number found"
end

scan — Find All Matches #

text = "Emails: [email protected], [email protected], and [email protected]"
email_pattern = /[\w.+-]+@[\w-]+\.[a-z.]+/i

all_emails = text.scan(email_pattern)
puts all_emails.inspect
# => ["[email protected]", "[email protected]", "[email protected]"]

# scan with groups — returns an array of arrays
text2 = "Rina:85, Budi:92, Citra:78"
data = text2.scan(/(\w+):(\d+)/)
puts data.inspect
# => [["Rina", "85"], ["Budi", "92"], ["Citra", "78"]]

# Direct conversion to a Hash
scores = text2.scan(/(\w+):(\d+)/).to_h { |name, score| [name, score.to_i] }
puts scores.inspect   # => {"Rina"=>85, "Budi"=>92, "Citra"=>78}

gsub and sub — Replace with Regex #

text = "price: 15000, stock: 200, weight: 1500"

# sub — replace only the FIRST
puts text.sub(/\d+/, "XXX")
# => "price: XXX, stock: 200, weight: 1500"

# gsub — replace ALL
puts text.gsub(/\d+/, "XXX")
# => "price: XXX, stock: XXX, weight: XXX"

# gsub with back-reference \1
puts "2024-08-15".gsub(/(\d{4})-(\d{2})-(\d{2})/, '\3/\2/\1')
# => "15/08/2024"  (change the date format)

# gsub with named captures
puts "John Doe".gsub(/(?<first>\w+) (?<last>\w+)/, '\k<last>, \k<first>')
# => "Doe, John"

# gsub with a block — the most flexible
price_str = "laptop: 15000000, mouse: 350000"
result = price_str.gsub(/\d+/) do |number|
  "Rp #{number.to_i.to_s.reverse.gsub(/(\d{3})(?=\d)/, '\\1.').reverse}"
end
puts result
# => "laptop: Rp 15.000.000, mouse: Rp 350.000"

# gsub for input sanitization
def clean_html(text)
  text
    .gsub(/<[^>]+>/, "")         # remove HTML tags
    .gsub(/&amp;/, "&")          # decode HTML entities
    .gsub(/&lt;/, "<")
    .gsub(/&gt;/, ">")
    .gsub(/\s+/, " ")            # normalize whitespace
    .strip
end

split — Breaking Strings with Regex #

# split with a plain string
"a,b,c".split(",")     # => ["a", "b", "c"]

# split with regex — more flexible
"a , b  ,  c".split(/\s*,\s*/)   # => ["a", "b", "c"]  (ignore spaces around commas)
"one  two   three".split(/\s+/)  # => ["one", "two", "three"]  (one or more spaces)

# Simple CSV parsing
csv_row = "Rina,28,Bandung,active"
columns = csv_row.split(",")
puts columns.inspect   # => ["Rina", "28", "Bandung", "active"]

# Split sentences
sentence = "This is the first sentence. The second! And the third?"
sentence.split(/(?<=[.!?])\s+/)
# => ["This is the first sentence.", "The second!", "And the third?"]

Common Regex Patterns for Indonesian Validation #

Here’s a collection of ready-to-use regex patterns relevant for Indonesian-language applications:

# Email
EMAIL_PATTERN = /\A[\w+\-.]+@[a-z\d\-.]+\.[a-z]+\z/i.freeze

# Indonesian phone number — supports 08xx, +628xx, 628xx
ID_PHONE_PATTERN = /\A(\+62|62|0)[0-9]{8,12}\z/.freeze

# NIK (Indonesian national ID number) — 16 digits
NIK_PATTERN = /\A\d{16}\z/.freeze

# NPWP (tax ID) — format: XX.XXX.XXX.X-XXX.XXX
NPWP_PATTERN = /\A\d{2}\.\d{3}\.\d{3}\.\d-\d{3}\.\d{3}\z/.freeze

# Indonesian postal code — 5 digits
POSTAL_CODE_PATTERN = /\A[1-9]\d{4}\z/.freeze

# Indonesian vehicle license plate
PLATE_PATTERN = /\A[A-Z]{1,2}\s?\d{1,4}\s?[A-Z]{1,3}\z/i.freeze

# Indonesian date format DD/MM/YYYY or DD-MM-YYYY
ID_DATE_PATTERN = /\A(0?[1-9]|[12]\d|3[01])[\/\-](0?[1-9]|1[0-2])[\/\-]\d{4}\z/.freeze

# URL
URL_PATTERN = /\Ahttps?:\/\/[\w\-]+(\.[\w\-]+)+([\/\?#]\S*)?\z/i.freeze

# Slug (URL-friendly string)
SLUG_PATTERN = /\A[a-z0-9]+(?:-[a-z0-9]+)*\z/.freeze

# Price in Rupiah (optional Rp and dots as thousands separators)
PRICE_PATTERN = /\A(?:Rp\.?\s?)?\d{1,3}(?:\.\d{3})*(?:,\d{2})?\z/.freeze

# Usage
module Validator
  def self.email_valid?(email)
    email.to_s.match?(EMAIL_PATTERN)
  end

  def self.phone_valid?(number)
    number.to_s.gsub(/[\s\-()]/, "").match?(ID_PHONE_PATTERN)
  end

  def self.nik_valid?(nik)
    nik.to_s.match?(NIK_PATTERN)
  end

  def self.url_valid?(url)
    url.to_s.match?(URL_PATTERN)
  end
end

puts Validator.email_valid?("[email protected]")    # => true
puts Validator.email_valid?("not-an-email")        # => false
puts Validator.phone_valid?("08123456789")         # => true
puts Validator.phone_valid?("+628****7890")        # => true
puts Validator.phone_valid?("123")                 # => false
puts Validator.nik_valid?("3201234501010001")      # => true
puts Validator.url_valid?("https://ruby-lang.org") # => true

Debugging and Regex Performance #

# Test regex in IRB or with puts debugging
pattern = /(?<year>\d{4})-(?<month>\d{2})-(?<day>\d{2})/
text = "Date: 2024-08-15"

m = pattern.match(text)
puts m.inspect        # show the entire MatchData
puts m.named_captures.inspect  # => {"year"=>"2024", "month"=>"08", "day"=>"15"}
puts m.pre_match      # => "Date: "  (before the match)
puts m.post_match     # => ""        (after the match)

# Performance — match? is faster than match for boolean checks
require 'benchmark'
text = "[email protected]"
pattern = /\A[\w+\-.]+@[a-z\d\-.]+\.[a-z]+\z/i

Benchmark.bm(15) do |x|
  x.report("match:")  { 100_000.times { text.match(pattern) } }
  x.report("match?:") { 100_000.times { text.match?(pattern) } }
  x.report("=~:")     { 100_000.times { text =~ pattern } }
end
# match? is usually 2-3x faster than match because it doesn't allocate MatchData

# Avoid catastrophic backtracking
# DANGEROUS: patterns like /(\w+)+\z/ on long input can be very slow
# SAFE: write more specific, unambiguous patterns
Guidelines for writing good regex:
  ✓ Start with \A and end with \z for full validation
  ✓ Use named captures (?<name>pattern) for readability
  ✓ Use the x flag for long regex — it allows comments
  ✓ Use match? rather than match if you only need a boolean
  ✓ Escape special characters with Regexp.escape for user input
  ✓ Test the regex with a variety of valid AND invalid inputs
  ✗ Avoid greedy patterns that can cause excessive backtracking
  ✗ Don't use ^ and $ for validation — use \A and \z
  ✗ Don't write overly long regex without the x flag and comments

Summary #

  • /pattern/ for literals, Regexp.new for dynamic — always use Regexp.escape when building patterns from user input so special characters aren’t interpreted as regex syntax.
  • \A and \z, not ^ and $ — for full input validation, always use the \A (start of string) and \z (end of string) anchors. Patterns with ^/$ can be bypassed with multiline input.
  • Named captures (?<name>pattern) are far more expressive than numbered groups (\d+) — capture results can be accessed by meaningful names rather than index numbers.
  • match? is faster than match — use match? when you only need to know whether it matches, because it doesn’t allocate a MatchData object.
  • Greedy vs Lazy quantifiers+ and * are greedy (match as much as possible); add ? (+?, *?) to make them lazy (as little as possible).
  • Lookahead (?=) and lookbehind (?<=) for context without consuming characters — useful for extracting numbers preceded by a specific symbol.
  • scan with groups returns an array of arrays — can be converted directly to a Hash with .to_h.
  • gsub with a block is the most flexible — every match can be processed with full Ruby logic, not just simple string replacement.
  • The x flag for complex regex — allows whitespace and inline comments so long patterns stay readable and maintainable.
  • Store validation patterns as frozen constants — avoid recompiling the same pattern repeatedly in a hot path.

← Previous: Date & Time   Next: RubyGems →

About | Author | Content Scope | Editorial Policy | Privacy Policy | Disclaimer | Contact