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(/&/, "&") # decode HTML entities
.gsub(/</, "<")
.gsub(/>/, ">")
.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.newfor dynamic — always useRegexp.escapewhen building patterns from user input so special characters aren’t interpreted as regex syntax.\Aand\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 thanmatch— usematch?when you only need to know whether it matches, because it doesn’t allocate aMatchDataobject.- 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.scanwith groups returns an array of arrays — can be converted directly to a Hash with.to_h.gsubwith a block is the most flexible — every match can be processed with full Ruby logic, not just simple string replacement.- The
xflag 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.