Skip to content
Control Plane Labs

Regex patterns you will actually use

A working regex reference: the syntax worth memorizing, patterns for logs, semver, URIs and email, flag semantics, dialect differences, and how to avoid ReDoS.

Ben Ennis

Published July 27, 2026

Regex has a reputation problem because most of what people write is either trivial or unmaintainable, with very little in between. The useful middle is narrow and learnable: a handful of constructs cover log parsing, input validation, refactoring, and log-shipper configuration, and everything past that belongs in a parser. What follows is the middle, with the traps marked.

The syntax that earns its keep

.          any character except newline (unless the s flag is set)
\d \w \s   digit, word character, whitespace
\D \W \S   the negations
[abc]      character class
[^abc]     negated class
[a-z0-9]   ranges
a|b        alternation
^ $        start and end of input, or of line with the m flag
\b         word boundary — a zero-width assertion, not a character

Quantifiers, and the greedy/lazy distinction that causes most incorrect matches:

*    zero or more, greedy
+    one or more, greedy
?    zero or one, greedy
{2,5} between two and five, greedy
*? +? ?? {2,5}?   the lazy versions — match as little as possible

Two of those deserve more than a line. \b is a zero-width assertion between a word character and a non-word character, which means its definition of “word” is [A-Za-z0-9_] and nothing else — so \bcafé\b does not behave the way a reader expects, because the accented character is not a word character in the ASCII sense. And anchors change meaning with the m flag: ^ and $ normally mean start and end of the whole input, but with m they match at every line break, which is what you want when parsing a log blob in one pass and a bug when validating a single field. A validator without m is the safe default, because a value containing a newline cannot then smuggle a valid-looking second line past your check.

Greedy .* inside a delimited pattern is the single most common bug. Matching "([^"]*)" is almost always correct where "(.*)" is almost always wrong, because the greedy version runs to the last quote on the line rather than the next one. The lazy form "(.*?)" also works, but the negated character class is faster and states the intent.

Groups:

(abc)         capturing group, numbered left to right by opening paren
(?:abc)       non-capturing — use this by default
(?<name>abc)  named group, referenced as \k<name> or $<name>
(?=abc)       lookahead
(?!abc)       negative lookahead
(?<=abc)      lookbehind
(?<!abc)      negative lookbehind

Named groups and lookbehind are both standardized in JavaScript and documented on MDN under named capturing groups. Prefer named groups in anything another person will read: $<version> in a replacement survives someone inserting a group in front of it, and $3 does not.

Flags, and the one that will bite you

JavaScript flags, per the MDN RegExp reference:

g   global — find all matches, and track position in lastIndex
i   case-insensitive
m   multiline — ^ and $ match at line breaks
s   dotAll — . also matches newline
u   unicode — enables \p{...}, treats the pattern as code points
v   unicodeSets — u plus set operations inside classes
y   sticky — match only at lastIndex
d   hasIndices — populate match.indices with start/end offsets

The g flag is stateful, and this is the classic JavaScript regex bug:

const re = /^\d+$/g;
re.test("123");   // true
re.test("123");   // false — lastIndex is now 3

RegExp.prototype.test and exec both advance lastIndex when g or y is set, so a module-level regex with g reused across calls returns alternating results. Either construct the regex where you use it, drop the g flag for boolean checks, or reset lastIndex yourself. For iterating all matches, use String.prototype.matchAll, which returns an iterator of full match objects including named groups.

The u flag is what makes Unicode property escapes work — \p{Lu} for uppercase letters, \p{Script=Greek}, \p{Emoji_Presentation} — as documented under Unicode character class escapes. The v flag, from the unicodeSets proposal, adds intersection and difference inside character classes, which is how you express “a letter that is not ASCII” without a lookahead. The formal definitions for all of this live in the ECMAScript text processing chapter.

Patterns worth copying

Semantic version. Do not write your own. The semver.org FAQ publishes an official regular expression with named groups for major, minor, patch, prerelease, and build metadata. Use that one; it handles the prerelease ordering rules that hand-rolled versions get wrong.

URI. RFC 3986 Appendix B contains the reference regex for splitting a URI into scheme, authority, path, query, and fragment. It is deliberately permissive — it parses, it does not validate. For validation in a browser or Node, construct a URL and catch the exception instead.

Email. There is no correct email regex. RFC 5322 permits comments, quoted local parts, and folding whitespace, and matching it fully takes hundreds of characters. The HTML specification takes the honest route: its valid e-mail address production is described as a willful violation of RFC 5322 precisely because the full grammar is not useful for form validation. Use the HTML production, or [^@\s]+@[^@\s]+\.[^@\s]+, and then send a confirmation email — which is the only real validation anyway.

Log lines. Anchor and use negated classes rather than .*:

^(?<ip>\S+) \S+ \S+ \[(?<ts>[^\]]+)\] "(?<method>[A-Z]+) (?<path>[^ "]+) [^"]*" (?<status>\d{3}) (?<bytes>\d+|-)

That parses the combined log format and stays linear on malformed input, because every field is bounded by a class that excludes its own terminator.

Kubernetes-style names. ^[a-z0-9]([-a-z0-9]*[a-z0-9])?$ is the RFC 1123 label rule Kubernetes uses for most object names, so it is a good validator for anything that will become a DNS label downstream.

Substitution, which has its own syntax

Replacement strings are not regex patterns, and they have a small dollar-sign language of their own that people routinely misremember:

$1 $2       numbered group
$<name>     named group
$&          the entire match
$$          a literal dollar sign
$` $'       the text before and after the match
// Reorder a date: 2026-07-27 -> 27/07/2026
"2026-07-27".replace(/(?<y>\d{4})-(?<m>\d{2})-(?<d>\d{2})/, "$<d>/$<m>/$<y>");

// A function replacer gets the groups as an object
line.replace(/(?<key>\w+)=(?<val>\S+)/g, (...args) => {
  const { key, val } = args.at(-1);
  return `${key}=${redact(val)}`;
});

The function form is the one to reach for whenever the replacement depends on the matched text, because it avoids building a second pattern to re-parse your own capture groups. Note that replace with a g-less pattern changes the first match only, while replaceAll requires either a string or a g-flagged regex and throws otherwise — a deliberate design choice to stop the silently-only-once bug.

Editor-side, the same syntax is what makes regex refactors safe. A search for (?<fn>get[A-Z]\w+)\(\) with a replacement of $<fn> across a repository is reviewable in a diff; a series of manual edits is not. The discipline that matters is running the search first, reading the match count, and only then applying the replacement. If the count is not what you predicted, your pattern is wrong and the replacement would have been wrong everywhere.

ReDoS: the failure mode that takes down a service

Backtracking engines can take exponential time on inputs that look harmless. The pattern ^(a+)+$ against a long string of a characters followed by one b is the textbook example: the engine tries every partition of the input before concluding there is no match. OWASP’s ReDoS page catalogues the shapes to watch for. The warning signs are nested quantifiers, alternations where branches can match the same text, and any quantified group containing another quantifier.

Three defenses, in order of effectiveness:

  1. Do not accept untrusted patterns. A regex from a user is code execution with a time budget. If a product feature requires it, run it in a separate process with a hard timeout and a memory cap.
  2. Use a linear-time engine for untrusted input. Go’s regexp package is RE2-based and guarantees time linear in the input size, as does the Rust regex crate. Both drop backreferences and lookaround in exchange, which is the trade you want on a hot path. RE2’s syntax is documented in the RE2 syntax wiki.
  3. Bound the input. Truncate before matching. A 4 KB cap turns a catastrophic pattern into a slow one.

If you are building the pattern from user-supplied text rather than accepting a whole pattern, escape it. JavaScript now has RegExp.escape for exactly this, which removes the hand-rolled replace(/[.*+?^${}()|[\]\\]/g, "\\$&") helper that every codebase used to carry.

Dialects, briefly

Assume nothing transfers between tools without checking, and assume the failure will be silent rather than a syntax error, because most of these differences produce a pattern that still compiles and matches the wrong thing:

  • JavaScript has no \A/\z, no atomic groups, no recursion, and no possessive quantifiers. It does have named groups, lookbehind, and Unicode property escapes.
  • PCRE2, used by PHP, nginx, and grep -P, is the largest dialect — atomic groups, recursion, and conditionals included. Its syntax summary is at pcre.org.
  • Python’s re uses (?P<name>...) for named groups rather than (?<name>...), and re.fullmatch is usually what you want instead of anchoring by hand. See the re documentation.
  • POSIX tools are a different world: basic regular expressions require backslashes before + and ?, extended regular expressions do not, and bracket expressions like [[:alpha:]] are the portable way to express classes. The grammar is in the POSIX regular expressions chapter. grep -E gets you extended syntax; \d is not POSIX and does not work in either mode.

When to stop

Regex is a matcher, not a parser. If the input is nested, has quoting rules, or has a specification, use a parser — HTML, JSON, YAML, CSV with embedded commas, and SQL are all in this category. The tell is a pattern that grows a new alternation every time you find a new input; each addition makes the previous behavior less predictable, and at some point you have written a parser badly instead of writing one deliberately.

There is a second stopping point that is easier to miss: the pattern that works but nobody can modify. A validator built from four nested lookaheads is correct until the requirement changes, at which point the safe move is to rewrite it from scratch rather than edit it, and that is a signal the logic should have been ordinary code from the start. Password rules are the canonical example — a lookahead per character class reads as clever and fails as soon as someone asks for “three of these five categories”, which four lines of imperative code express directly.

The practical workflow is: build the pattern against real sample data, name your groups, comment the pattern in the code that uses it, and test the negative cases as carefully as the positive ones. The regex tester on this site does the first part with live match highlighting and a capture-group table, and it caps iterations so a catastrophic pattern cannot lock the tab while you are experimenting.

Frequently asked questions

Why does my regex return true then false for the same string?+
The g or y flag is set and the regex object is being reused. test and exec advance lastIndex on each call. Drop g for boolean checks, or reset lastIndex to 0.
What is the correct regex for validating an email address?+
There is not one. RFC 5322 allows constructs no practical regex handles. Use the HTML specification's valid e-mail address production, which is explicitly a willful violation of the RFC, and confirm ownership by sending mail.
How do I make . match newlines?+
Add the s (dotAll) flag. Without it, . excludes line terminators. Note that m is a different flag: it changes what ^ and $ anchor to, not what . matches.
Is it safe to let users supply regular expressions?+
Not on a backtracking engine without isolation. Treat it as untrusted code: separate process, hard timeout, memory cap, or use a linear-time engine such as RE2 or the Rust regex crate. See the OWASP ReDoS page.
Why does my pattern work in JavaScript but not in grep?+
Different dialects. \d, \w, and non-greedy quantifiers are not POSIX. Use grep -E for extended syntax with [[:digit:]], or grep -P where PCRE is available.

Tags: #regex, #javascript, #text-processing, #web-dev