Practical Recipes
Patterns you can copy, the API surface in each environment, and the performance failure mode that takes production down. Every pattern here was run against both matching and non-matching inputs.
Recipes
Section titled “Recipes”Integers and decimals
Section titled “Integers and decimals”const INT = /^[+-]?\d+$/;const DECIMAL = /^[+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?$/;
DECIMAL.test('42') // => trueDECIMAL.test('-3.5') // => trueDECIMAL.test('+.5') // => trueDECIMAL.test('1e10') // => trueDECIMAL.test('1.') // => trueDECIMAL.test('1e') // => falseDECIMAL.test('') // => falseThe alternation \d+(?:\.\d*)?|\.\d+ is the important part: it accepts 1, 1., 1.5, and .5 while rejecting a lone .. Order matters — the \d+ branch must come first.
Quoted strings, with escapes
Section titled “Quoted strings, with escapes”const QUOTED = /"(?:[^"\\]|\\.)*"/g;
const src = String.raw`say "hi \"there\"" and "bye"`;src.match(QUOTED)// => ['"hi \\"there\\""', '"bye"']import res = 'say "hi \\"there\\"" and "bye"'re.findall(r'"(?:[^"\\]|\\.)*"', s)# => ['"hi \\"there\\""', '"bye"']Read the body as “either a character that is not a quote or backslash, or a backslash followed by anything”. That alternation is unambiguous — at every position exactly one branch can apply — which is what keeps it fast. The naive ".*?" breaks on \".
key=value pairs
Section titled “key=value pairs”const KV = /^\s*([A-Za-z_][\w.-]*)\s*=\s*(.*?)\s*$/;' log.level = debug '.match(KV).slice(1) // => ["log.level", "debug"]import redict(re.findall(r"(\w+)=(\S+)", "host=db1 port=5432 user=app"))# => {'host': 'db1', 'port': '5432', 'user': 'app'}# Read a .env-style file, ignoring comments and blanksgrep -E '^[A-Za-z_][A-Za-z0-9_]*=' .env
# Print just the keyssed -nE 's/^([A-Za-z_][A-Za-z0-9_]*)=.*/\1/p' .envA correct IPv4 regex needs per-octet range checking. Build it from one octet pattern:
const OCT = '(25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9])';const IPV4 = new RegExp(`^${OCT}(\\.${OCT}){3}$`);
IPV4.test('0.0.0.0') // => trueIPV4.test('192.168.1.1') // => trueIPV4.test('255.255.255.255') // => trueIPV4.test('256.1.1.1') // => falseIPV4.test('1.2.3') // => falseIPV4.test('01.2.3.4') // => false — leading zeros rejectedAlternatives are ordered longest-first (25[0-5] before 2[0-4][0-9] before 1[0-9]{2}) because the engine takes the first branch that lets the overall match succeed.
For log scanning where false positives are cheap, the loose version is fine and much shorter:
grep -oE '([0-9]{1,3}\.){3}[0-9]{1,3}' access.log | sort -uISO dates
Section titled “ISO dates”const ISO_DATE = /^(\d{4})-(0[1-9]|1[0-2])-(0[1-9]|[12]\d|3[01])$/;
ISO_DATE.test('1999-12-31') // => trueISO_DATE.test('2024-13-01') // => false — month out of rangeISO_DATE.test('2024-00-10') // => falseISO_DATE.test('2024-02-31') // => true — regex cannot know FebruaryA regex can enforce the shape and coarse ranges. It cannot know that February has 28 or 29 days. Validate the shape with the regex, then round-trip through a date library.
Full ISO 8601 timestamps:
const ISO_TS = /^\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})?$/;ISO_TS.test('2024-01-31T13:55:36.123Z') // => trueISO_TS.test('2024-01-31 13:55:36+02:00') // => trueURLs — an approximation
Section titled “URLs — an approximation”const URL_RE = /\bhttps?:\/\/[^\s<>"']+/gi;'see https://a.b/c?d=1 and http://x.y'.match(URL_RE)// => ["https://a.b/c?d=1", "http://x.y"]The same caveat applies doubly to email. There is no short correct RFC 5322 regex. A pragmatic screen, followed by a confirmation email, is the only sound approach:
const EMAIL_ISH = /^[^@\s]+@[^@\s.]+(?:\.[^@\s.]+)+$/;EMAIL_ISH.test('x+y@mail.example.com') // => trueEMAIL_ISH.test('a@b') // => false — no TLDEMAIL_ISH.test('a b@c.de') // => falseOther common shapes
Section titled “Other common shapes”const HEX_COLOR = /^#(?:[0-9a-f]{3}|[0-9a-f]{6})$/i;HEX_COLOR.test('#fff') // => trueHEX_COLOR.test('#FFAA00') // => trueHEX_COLOR.test('#ffff') // => false
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;UUID.test('123e4567-e89b-12d3-a456-426614174000') // => true
const SEMVER = /^(\d+)\.(\d+)\.(\d+)(?:-([\w.-]+))?(?:\+([\w.-]+))?$/;'1.2.3-rc.1+build5'.match(SEMVER).slice(1)// => ["1", "2", "3", "rc.1", "build5"]Text cleanup one-liners:
' hi '.replace(/^\s+|\s+$/g, '') // => "hi" (trim)'a b\t\tc'.replace(/\s+/g, ' ') // => "a b c" (collapse whitespace)'getUserName'.replace(/([a-z0-9])([A-Z])/g, '$1_$2').toLowerCase()// => "get_user_name"'get_user_name'.replace(/_([a-z])/g, (_, c) => c.toUpperCase())// => "getUserName"Using regex in JavaScript
Section titled “Using regex in JavaScript”| Call | Returns |
|---|---|
re.test(str) |
boolean |
re.exec(str) |
match array or null; iterate with /g |
str.match(re) |
without g: match array. With g: array of strings, no groups |
str.matchAll(re) |
iterator of full match objects; requires g |
str.replace(re, x) |
new string; x is a template or a function |
str.replaceAll(re, x) |
same, but throws unless the regex has g |
str.split(re) |
array; capture groups are included in the output |
str.search(re) |
index of first match, or -1 |
// Groups: use matchAll, not match, when you need them for every matchconst line = 'host=db1 port=5432';[...line.matchAll(/(?<k>\w+)=(?<v>\S+)/g)].map(m => [m.groups.k, m.groups.v])// => [["host","db1"], ["port","5432"]]
'a1 b2'.match(/(\w)(\d)/g) // => ["a1", "b2"] — groups lostReplacement templates: $& whole match, $1…$9 numbered groups, $<name> named groups, $` and $' for the text before/after, $$ for a literal $.
'John Smith'.replace(/(\w+) (\w+)/, '$2, $1') // => "Smith, John"A function replacement receives (match, p1, p2, …, offset, string, groups) and returns the replacement — the cleanest way to compute something per match:
'a1b2'.replace(/\d/g, d => String(Number(d) * 2)) // => "a2b4"split with a capturing group keeps the delimiters:
'a1b2'.split(/(\d)/) // => ["a", "1", "b", "2", ""]'a1b2'.split(/\d/) // => ["a", "b", ""]Using regex in Python
Section titled “Using regex in Python”| Call | Returns |
|---|---|
re.match(p, s) |
match at the start only, or None |
re.fullmatch(p, s) |
match spanning the entire string, or None |
re.search(p, s) |
first match anywhere, or None |
re.findall(p, s) |
list of strings (0 or 1 group) or tuples (2+ groups) |
re.finditer(p, s) |
iterator of match objects — the predictable one |
re.sub(p, r, s) |
replaced string; r may be a function |
re.subn(p, r, s) |
(string, count) |
re.split(p, s) |
list; capture groups are included |
import re
re.match(r"\d+", "a1") # => None — must match at position 0re.search(r"\d+", "a1") # => <Match '1'>re.fullmatch(r"\d+", "123") # => <Match '123'>findall changes shape depending on group count, which is why finditer is safer:
re.findall(r"\w+=\S+", "a=1 b=2") # => ['a=1', 'b=2'] 0 groupsre.findall(r"(\w+)=\S+", "a=1 b=2") # => ['a', 'b'] 1 group -> flatre.findall(r"(\w+)=(\S+)","a=1 b=2") # => [('a','1'),('b','2')] 2 groups -> tuples
for m in re.finditer(r"(\w+)=(\S+)", "a=1 b=2"): print(m.group(1), m.span())# a (0, 3)# b (4, 7)Function replacements receive the match object:
re.sub(r"\d+", lambda m: str(int(m.group()) * 2), "a1 b22") # => 'a2 b44're.subn(r"a", "b", "aaa") # => ('bbb', 3)re.sub(r"\d+", "N", "a1 b22", count=1) # => 'aN b22'Compiled patterns
Section titled “Compiled patterns”re.compile returns a pattern object whose methods drop the pattern argument. Python already caches recently used patterns internally, so compiling is not required for correctness — but it removes a cache lookup per call and it is measurably faster in tight loops.
import re, time
KV = re.compile(r"^(\w+)=(\S+)$")for line in lines: m = KV.match(line) if m: key, value = m.groups()Measured over 200,000 lines on CPython 3.14: 0.086 s with a compiled pattern versus 0.144 s going through re.match each time — about 40% saved. Compile once at module scope; never inside the loop.
Using regex on the command line
Section titled “Using regex on the command line”# --- grep: find lines ---grep -E 'PATTERN' file # extended regexgrep -oE 'PATTERN' file # print only the matched textgrep -iE 'PATTERN' file # case-insensitivegrep -c 'PATTERN' file # count matching linesgrep -v 'PATTERN' file # invert: lines that do NOT matchgrep -w 'cat' file # whole word (same as \bcat\b)grep -x 'cat' file # whole linegrep -rn -E 'TODO' src/ # recursive, with line numbersgrep -P 'PCRE' file # PCRE2 — lookaround, \d, \K# --- sed: rewrite lines ---sed -E 's/old/new/' file # first occurrence per linesed -E 's/old/new/g' file # all occurrencessed -E 's/old/new/2' file # only the 2nd occurrencesed -nE 's/re/\1/p' file # print ONLY substituted lines (extraction idiom)sed -i.bak -E 's/a/b/g' file # edit in place, keeping file.baksed -E '/^#/d' file # delete comment lines# --- awk: fields plus regex ---awk '/PATTERN/ { print }' file # like grepawk '$3 ~ /^[45]/ { print $1, $3 }' file # regex on one fieldawk '$0 !~ /debug/ { print }' file # negatedawk '{ gsub(/[0-9]+/, "N"); print }' file # substituteawk 'match($0, /[0-9]+/) { print substr($0, RSTART, RLENGTH) }' fileA worked example
Section titled “A worked example”cat > access.log <<'EOF'10.0.0.1 - - [10/Oct/2024:13:55:36] "GET /index.html HTTP/1.1" 200 2326192.168.1.7 - - [10/Oct/2024:13:55:40] "POST /api/login HTTP/1.1" 401 12810.0.0.1 - - [10/Oct/2024:13:56:01] "GET /style.css HTTP/1.1" 304 0EOF# Unique client IPsgrep -oE '^([0-9]{1,3}\.){3}[0-9]{1,3}' access.log | sort -u# => 10.0.0.1# => 192.168.1.7
# Only 4xx and 5xx responsesgrep -E '" [45][0-9]{2} ' access.log# => 192.168.1.7 ... "POST /api/login HTTP/1.1" 401 128
# Reformat with sed capturessed -nE 's/^([0-9.]+) .*"([A-Z]+) ([^ ]+) [^"]*" ([0-9]{3}).*/\4 \2 \3 (\1)/p' access.log# => 200 GET /index.html (10.0.0.1)# => 401 POST /api/login (192.168.1.7)# => 304 GET /style.css (10.0.0.1)
# Same job in awk, using fields (status is $8 in this format)awk '$8 ~ /^[45]/ { print $1, $8 }' access.log# => 192.168.1.7 401
# Extract the quoted requestawk 'match($0, /"[A-Z]+ [^"]+"/) { print substr($0, RSTART, RLENGTH) }' access.logPerformance
Section titled “Performance”Backtracking engines (JS, Python, PCRE, Java, .NET) have no time guarantee. Most patterns run fine; a small class of patterns runs in exponential time.
Cheap wins
Section titled “Cheap wins”- Compile once. Hoist
re.compile/new RegExpout of loops. - Anchor when you can.
^lets the engine fail at position 0 instead of retrying at every offset. - Use bounded classes.
[^,]+cannot overshoot;.*can, and then has to walk back. - Put the cheap, discriminating test first. In an alternation, order branches by likelihood.
- Pre-filter with a plain string check.
if "error" in line:before running an expensive regex skips 99% of lines. LC_ALL=Cfor ASCII-only shell work — it disables multibyte decoding in grep.- Use
grep -Fwhen the pattern has no metacharacters. Fixed-string search is far faster.
Catastrophic backtracking and ReDoS
Section titled “Catastrophic backtracking and ReDoS”The failure mode has one signature: a quantifier applied to something that itself can match the same text in more than one way, followed by something that can fail.
(a+)+ nested quantifiers over the same character(a|a)* alternatives that overlap(\s|\t)+ \s already includes \t([a-z]+)* inner and outer both match the same runWhen the rest of the pattern fails, the engine tries every way of splitting the input between the inner and outer quantifier. That count is exponential in the input length.
import re, timefor n in (20, 22, 24, 26): s = "a" * n + "!" t = time.perf_counter(); re.match(r"^(a+)+$", s) print(n, "%.3fs" % (time.perf_counter() - t))# 20 0.063s# 22 0.256s# 24 1.012s# 26 4.184sEvery added character doubles the time. At 40 characters this is roughly a month of CPU. JavaScript behaves the same way — /^(a+)+$/.test('a'.repeat(30) + 'b') blocks the event loop for over a minute in V8, and a Node process has one thread.
How to fix it
Section titled “How to fix it”1. Remove the ambiguity. Usually the nesting is redundant:
/^(a+)+$/ // catastrophic/^a+$/ // identical language, linear timeconst t = Date.now();/^a+$/.test('a'.repeat(40) + '!'); // => false, in 0 ms2. Use a negated class instead of a lazy dot. "[^"]*" cannot backtrack into itself; ".*?" can.
3. Make overlapping alternatives disjoint. (\s|\t)+ becomes \s+. (a|ab)* becomes (?:ab?)*.
4. Use atomic groups or possessive quantifiers where available (PCRE, Java, Ruby, Python 3.11+):
re.compile(r"^(?>a+)+$").match("a" * 24 + "b") # instantre.compile(r"^a++$").match("a" * 24 + "b") # instant5. Bound the input. Reject strings over a sane length before matching. A regex that is exponential in n is harmless when n ≤ 64.
if (input.length > 256) return false;return PATTERN.test(input);6. Use a linear-time engine for untrusted input. RE2 (Go’s regexp, Rust’s regex, the re2 bindings for Node and Python) guarantees linear time by construction. The price is no backreferences and no lookaround.
7. Add a timeout where the platform offers one. .NET has Regex match timeouts and Java allows an interruptible CharSequence. JavaScript and Python’s re do not — for those, run untrusted matching in a worker or subprocess you can kill.
Detecting it
Section titled “Detecting it”- regex101 warns about catastrophic backtracking as you type.
- Static analysers:
eslint-plugin-security(detect-unsafe-regex),redos-detector,semgreprules. - The quickest manual check: feed the pattern a long run of the character its quantifiers repeat, followed by one character that makes the match fail. If it does not return instantly, it is exponential.
node -e 'const re = /YOUR_PATTERN_HERE/;const s = "a".repeat(30) + "!";const t = Date.now(); re.test(s); console.log(Date.now() - t + "ms");'Key points
Section titled “Key points”- Build IPv4 and similar patterns from a named sub-pattern; order alternatives longest-first.
"(?:[^"\\]|\\.)*"is the correct quoted-string pattern;".*?"is not.- URL and email regexes are approximations — parse with
new URL/urlparsewhen correctness matters. - JS:
matchAll(needsg) preserves groups,matchwithgdoes not. Replacement uses$1/$<name>. - Python:
re.matchmeans “starts with”; usere.fullmatchto validate.findallchanges shape with group count — preferfinditer. - Compile patterns once; measured ~40% faster over 200k iterations in CPython.
- CLI idioms worth memorising:
grep -oE,grep -c,grep -F,sed -nE 's/…/\1/p',sed -i.bak,awk '$n ~ /re/'. - Nested or overlapping quantifiers cause exponential backtracking. Simplify the pattern, bound the input length, or use an RE2-based engine for untrusted data.