Skip to content

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.

const INT = /^[+-]?\d+$/;
const DECIMAL = /^[+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?$/;
DECIMAL.test('42') // => true
DECIMAL.test('-3.5') // => true
DECIMAL.test('+.5') // => true
DECIMAL.test('1e10') // => true
DECIMAL.test('1.') // => true
DECIMAL.test('1e') // => false
DECIMAL.test('') // => false

The 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.

const QUOTED = /"(?:[^"\\]|\\.)*"/g;
const src = String.raw`say "hi \"there\"" and "bye"`;
src.match(QUOTED)
// => ['"hi \\"there\\""', '"bye"']
import re
s = '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 \".

const KV = /^\s*([A-Za-z_][\w.-]*)\s*=\s*(.*?)\s*$/;
' log.level = debug '.match(KV).slice(1) // => ["log.level", "debug"]
import re
dict(re.findall(r"(\w+)=(\S+)", "host=db1 port=5432 user=app"))
# => {'host': 'db1', 'port': '5432', 'user': 'app'}
Terminal window
# Read a .env-style file, ignoring comments and blanks
grep -E '^[A-Za-z_][A-Za-z0-9_]*=' .env
# Print just the keys
sed -nE 's/^([A-Za-z_][A-Za-z0-9_]*)=.*/\1/p' .env

A 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') // => true
IPV4.test('192.168.1.1') // => true
IPV4.test('255.255.255.255') // => true
IPV4.test('256.1.1.1') // => false
IPV4.test('1.2.3') // => false
IPV4.test('01.2.3.4') // => false — leading zeros rejected

Alternatives 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:

Terminal window
grep -oE '([0-9]{1,3}\.){3}[0-9]{1,3}' access.log | sort -u
const ISO_DATE = /^(\d{4})-(0[1-9]|1[0-2])-(0[1-9]|[12]\d|3[01])$/;
ISO_DATE.test('1999-12-31') // => true
ISO_DATE.test('2024-13-01') // => false — month out of range
ISO_DATE.test('2024-00-10') // => false
ISO_DATE.test('2024-02-31') // => true — regex cannot know February

A 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') // => true
ISO_TS.test('2024-01-31 13:55:36+02:00') // => true
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') // => true
EMAIL_ISH.test('a@b') // => false — no TLD
EMAIL_ISH.test('a b@c.de') // => false
const HEX_COLOR = /^#(?:[0-9a-f]{3}|[0-9a-f]{6})$/i;
HEX_COLOR.test('#fff') // => true
HEX_COLOR.test('#FFAA00') // => true
HEX_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"
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 match
const 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 lost

Replacement 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", ""]
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 0
re.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 groups
re.findall(r"(\w+)=\S+", "a=1 b=2") # => ['a', 'b'] 1 group -> flat
re.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'

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.

Terminal window
# --- grep: find lines ---
grep -E 'PATTERN' file # extended regex
grep -oE 'PATTERN' file # print only the matched text
grep -iE 'PATTERN' file # case-insensitive
grep -c 'PATTERN' file # count matching lines
grep -v 'PATTERN' file # invert: lines that do NOT match
grep -w 'cat' file # whole word (same as \bcat\b)
grep -x 'cat' file # whole line
grep -rn -E 'TODO' src/ # recursive, with line numbers
grep -P 'PCRE' file # PCRE2 — lookaround, \d, \K
Terminal window
# --- sed: rewrite lines ---
sed -E 's/old/new/' file # first occurrence per line
sed -E 's/old/new/g' file # all occurrences
sed -E 's/old/new/2' file # only the 2nd occurrence
sed -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.bak
sed -E '/^#/d' file # delete comment lines
Terminal window
# --- awk: fields plus regex ---
awk '/PATTERN/ { print }' file # like grep
awk '$3 ~ /^[45]/ { print $1, $3 }' file # regex on one field
awk '$0 !~ /debug/ { print }' file # negated
awk '{ gsub(/[0-9]+/, "N"); print }' file # substitute
awk 'match($0, /[0-9]+/) { print substr($0, RSTART, RLENGTH) }' file
Terminal window
cat > access.log <<'EOF'
10.0.0.1 - - [10/Oct/2024:13:55:36] "GET /index.html HTTP/1.1" 200 2326
192.168.1.7 - - [10/Oct/2024:13:55:40] "POST /api/login HTTP/1.1" 401 128
10.0.0.1 - - [10/Oct/2024:13:56:01] "GET /style.css HTTP/1.1" 304 0
EOF
Terminal window
# Unique client IPs
grep -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 responses
grep -E '" [45][0-9]{2} ' access.log
# => 192.168.1.7 ... "POST /api/login HTTP/1.1" 401 128
# Reformat with sed captures
sed -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 request
awk 'match($0, /"[A-Z]+ [^"]+"/) { print substr($0, RSTART, RLENGTH) }' access.log

Backtracking engines (JS, Python, PCRE, Java, .NET) have no time guarantee. Most patterns run fine; a small class of patterns runs in exponential time.

  • Compile once. Hoist re.compile / new RegExp out 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=C for ASCII-only shell work — it disables multibyte decoding in grep.
  • Use grep -F when the pattern has no metacharacters. Fixed-string search is far faster.

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 run

When 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, time
for 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.184s

Every 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.

1. Remove the ambiguity. Usually the nesting is redundant:

/^(a+)+$/ // catastrophic
/^a+$/ // identical language, linear time
const t = Date.now();
/^a+$/.test('a'.repeat(40) + '!'); // => false, in 0 ms

2. 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") # instant
re.compile(r"^a++$").match("a" * 24 + "b") # instant

5. 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.

  • regex101 warns about catastrophic backtracking as you type.
  • Static analysers: eslint-plugin-security (detect-unsafe-regex), redos-detector, semgrep rules.
  • 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.
Terminal window
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");
'
  • 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 / urlparse when correctness matters.
  • JS: matchAll (needs g) preserves groups, match with g does not. Replacement uses $1 / $<name>.
  • Python: re.match means “starts with”; use re.fullmatch to validate. findall changes shape with group count — prefer finditer.
  • 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.