Skip to main content
Regex

Regex cheat sheet: how to read a pattern you didn't write

A scannable reference for the regex syntax developers actually re-Google — anchors, quantifiers, character classes, groups, lookarounds and flags — plus the official semver pattern annotated piece by piece, and the three mistakes that have taken down real production systems.

Thien Nguyen
By Thien Nguyen
Updated August 2, 2026 · 12 min read

A PR lands with a one-line diff. The line is 180 characters of punctuation. The commit message says "fix version parsing." You find the opening paren, lose your place somewhere around the fourth backslash, notice CI is green, and hit approve.

the moment you try to trace a nested group by eye

Regex doesn't feel unreadable because it's conceptually hard. It feels unreadable because roughly thirty tokens do all the work, and you personally meet each one about twice a year. That's not a skill problem, it's a recall problem — which is exactly what a reference is for.

So: below is the syntax, grouped the way you actually look it up, with the cross-engine differences that bite. Everything is JavaScript unless noted, because that's where most of us hit it first. If you want a specific pattern narrated back at you in English rather than looked up token by token, the regex explainer does that part.

Read a pattern outside-in, not left to right. Check the anchors first (is it pinned to the whole string or free-floating?), then split on the top-level alternations, then peel the groups one nesting level at a time. Left-to-right is how the engine reads it; outside-in is how a human can.

Anchors and boundaries

The tokens that match a position, not a character. Getting these wrong is how a "validator" ends up accepting a string that merely contains something valid.

TokenMatchesWorth knowing
^Start of stringWith the m flag, start of every line instead
$End of stringIn JS, strictly the end. Python's $ also matches just before a trailing newline — \Z is Python's absolute end
\bWord boundaryThe seam between a \w char and a non-\w char. \bcat\b won't match inside "concatenate"
\BNot a word boundaryRare, but handy for "inside a word" searches
\A \zAbsolute start / end.NET, Java, PCRE, Ruby, Go — not JavaScript. Go has \A and \z but no \Z

Character classes

TokenMatches
.Any character except line terminators, unless the s flag is set
\d \DA digit / not a digit
\w \WA word char / not one
\s \SWhitespace / not — includes tabs, newlines, NBSP and the Unicode space family
[abc]Any one of those characters
[^abc]Any one character that isn't one of those
[a-z0-9_]Ranges and members, combined
\p{L}, \p{Script=Greek}A Unicode property — requires the u or v flag

Two of these lie to you if you assume Unicode. In JavaScript \d is exactly [0-9] — never the Arabic-Indic or Devanagari digits — while Python 3's \d on a str pattern matches Unicode digits by default. And JS \w is exactly [A-Za-z0-9_]: no accents, no CJK, no Cyrillic. A username validator built on \w quietly rejects "José". Reach for \p{L} with the u flag when you mean "a letter" rather than "an ASCII letter."

Quantifiers

GreedyLazyMeaning
**?Zero or more
++?One or more
???Zero or one — optional
{3}Exactly three
{2,}{2,}?Two or more
{2,4}{2,4}?Two to four

Greedy is the default: the quantifier grabs as much as it can, then gives characters back one at a time until the rest of the pattern fits. Lazy takes the minimum first and grows only when forced. There's a third mode you can't use in JavaScript — possessive quantifiers (*+, ++, {2,4}+), which grab everything and refuse to give any of it back. Java, PCRE and Ruby have had them for years; Python added them in 3.11 alongside atomic groups. They're the cleanest fix for the backtracking problem further down, and their absence in JS is why [^>]* is the idiomatic JS workaround.

Greedy-versus-lazy is the single most common source of "the pattern matches, just not what I meant" — there's a fuller treatment in regex patterns every developer should know.

Groups, alternation and backreferences

SyntaxWhat it is
(...)Capturing group — numbered left to right by opening paren
(?:...)Non-capturing group — grouping only, no capture slot
(?<name>...)Named capture, ES2018. Read it back as m.groups.name
\1, \2Backreference — "the same text group 1 matched"
\k<name>Named backreference
a|bAlternation — try a, else b
(?>...)Atomic group — PCRE/Java/.NET/Python 3.11+, not JavaScript

The one that catches people: alternation in a backtracking engine is leftmost-first, not longest-match. /foo|foobar/ run against "foobar" returns "foo" and stops, because the first branch succeeded and nothing forced it to reconsider. Order your alternatives longest-first when the branches share a prefix.

Prefer (?:...) unless you actually need the capture. Every capturing group you add renumbers everything after it, so a "harmless" refactor silently reassigns \2 to a different piece of the string — which is exactly the kind of bug that survives code review.

Lookaheads and lookbehinds

All four are zero-width: they assert something about what's next to the current position and then consume nothing, so the match position doesn't move.

SyntaxNameReads as
(?=...)Positive lookahead"…followed by"
(?!...)Negative lookahead"…not followed by"
(?<=...)Positive lookbehind"…preceded by"
(?<!...)Negative lookbehind"…not preceded by"

The everyday use is asserting several independent things about one string without consuming it — a password rule expressed as (?=.*[a-z])(?=.*[A-Z])(?=.*\d) checks three conditions at position zero, each one scanning ahead and rewinding. The other everyday use is subtraction: \d+(?!%) finds numbers that aren't percentages, and (?<!\$)\d+ finds ones that aren't prices.

Lookbehind was the last piece to land in browsers — V8 shipped it in 2018 with ES2018, but Safari didn't until 16.4 in March 2023. If you're supporting anything older than that, a lookbehind is a SyntaxError at parse time, which takes out the entire script file, not just the match.

Flags

FlagPropertyEffect
gglobalFind all matches, not just the first
iignoreCaseCase-insensitive
mmultiline^ and $ match at line breaks
sdotAll. also matches newlines
uunicodeProper code-point handling, enables \p
vunicodeSetsES2024 superset of u: set intersection/difference inside classes, multi-code-point properties
ystickyMatch must start exactly at lastIndex
dhasIndicesES2022 — populates match.indices with start/end offsets per group

The g flag makes a regex stateful, and it will burn you. A RegExp object with g carries a mutable lastIndex, and .test() advances it. Call re.test(s) twice on the same string with the same object and you get true, then false. Any regex declared at module scope with g and used with .test() or .exec() is a latent heisenbug — drop the g, or build a fresh RegExp per call, or use String.prototype.matchAll which requires g and handles the state for you.

Worked example: the official semver pattern, line by line

Semver.org publishes a suggested regular expression for validating a version string. It is 179 characters of exactly the syntax above — near enough to that PR — and once you can read it, you can read most things:

^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$

Peeled apart:

^                       Pinned to the start. Without it, "v1.2.3" matches from index 1.

(0|[1-9]\d*)            MAJOR. Alternation: the literal 0, OR a non-zero digit followed
                        by any digits. That branch structure is the whole point — it is
                        what makes "01.0.0" invalid while keeping "0.1.0" valid.

\.                      A literal dot. Unescaped, "." would match any character, and
                        "1x2x3" would sail through.

(0|[1-9]\d*)            MINOR — identical rule.
\.
(0|[1-9]\d*)            PATCH — identical rule.

(?:-(                   Optional pre-release, introduced by a hyphen. Outer group is
                        non-capturing (?: because we don't want the hyphen in the
                        capture; the inner ( is capturing, so capture group 4
                        comes back as "beta.1" rather than "-beta.1".

  (?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)
                        One pre-release identifier. Three alternatives: bare 0, a
                        numeric identifier without leading zeros, or anything that
                        contains at least one letter or hyphen. The third branch is
                        what allows "beta", "rc-2" and "0alpha" while the second
                        still rejects "007".

  (?:\.(?: ...same... ))*
                        Zero or more further ".identifier" segments — this is how
                        "1.0.0-beta.11.x" is legal. The * is on the whole dot-plus-
                        identifier group, not on the dot.

))?                     The trailing ? makes the entire pre-release section optional.

(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?
                        Optional "+build.metadata", same repeat shape. Note \+ is
                        escaped — a bare + is a quantifier, and "(?:+..." throws.

$                       Pinned to the end. Together with ^, this is what turns
                        "find a version in here" into "this string IS a version".

The takeaway isn't the pattern, it's the shape: two anchors holding a sequence of alternation groups, with (?:...)? marking every optional section and (?:...)* marking every repeatable one. Almost every long real-world regex is that same skeleton with different characters inside. Paste it into the regex tester with 1.0.0-beta.11+exp.sha.5114f85 and watch which group catches what — the numbered captures make the structure obvious in a way that staring at the source never does.

The three mistakes that keep showing up

1. Assuming .test() means "the whole string is valid"

/[0-9]+/.test("12px") is true. So is /[0-9]+/.test("drop table users; 42"). Without ^ and $ you have written a search, not a validation — the engine only has to find your pattern somewhere. Every validator needs both anchors, and ^...$ with the m flag isn't good enough either, because m lets $ match at a newline and a multi-line payload slips through. If you need line anchoring and whole-string validation at once, ^ plus $ without m is the only combination that means what you think.

2. Escaping too little outside a class, too much inside one

Outside a character class, fourteen characters are special: . * + ? ^ $ ( ) [ ] { } | \. Miss one and the pattern still compiles — it just means something else. /^api.example.com$/ happily matches apiXexampleYcom; you wanted /^api\.example\.com$/.

Inside a class, almost nothing is special, and over-escaping is how patterns get unreadable. [.*+?] matches a literal dot, asterisk, plus or question mark — no backslashes needed. What does need care inside a class is position-dependent:

  • - is a range operator between two characters, and a literal anywhere it can't be: [-az], [az-] and [a\-z] all match a literal hyphen; [a-z] does not.
  • ^ negates only in the first position. [a^] matches a literal caret.
  • ] has to be escaped: [\]]. In JavaScript [] isn't "an empty set of exceptions", it's a class that matches nothing at all, and [^] matches anything including newlines.

And when you build a pattern from a string rather than a literal, every backslash doubles: new RegExp("\\d+") is /\d+/. If any part of that string came from a user, escape it — skip that step and a search box containing ( throws SyntaxError: Invalid regular expression: Unterminated group.

// RegExp.escape() landed with ES2025 (Node 24+). Fall back where you can't rely on it.
const escapeLiteral = (s) =>
  typeof RegExp.escape === "function" ? RegExp.escape(s) : s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");

3. Nested quantifiers, and the outage they cause

This is the one with a body count. When a quantifier is applied to a group that itself contains a quantifier over an overlapping character set — (a+)+, (\s*)*, (\w+\s?)+ — the number of ways the engine can split the input grows exponentially, and on a string that nearly matches, it tries all of them before admitting failure.

// Fine.
/^(a+)+$/.test("aaaaaaaaaaaaaaaaaaaaaaaaa");   // true, instant

// Add one character that can't match, and the engine explores 2^n splits.
/^(a+)+$/.test("aaaaaaaaaaaaaaaaaaaaaaaaa!");  // still running

Every character you add to that input roughly doubles the runtime. That's ReDoS, and it isn't theoretical: Cloudflare took a global 502 outage on 2 July 2019 from a single WAF rule containing .*(?:.*=.*) — a pattern their own postmortem simplifies to .*.*=.*. CPU hit ~100% across every machine serving HTTP traffic worldwide, and traffic dropped by about 80% for roughly 27 minutes before the WAF could be turned off. One sub-expression, in one rule, in one deploy.

Defences, in order of how much they actually help:

  1. Don't nest quantifiers over overlapping sets. (a+)+ is a+. Most instances of this are accidental and simplify away to nothing.
  2. Use a negated class to state the boundary[^>]* instead of .*? — so the engine can't cross the delimiter and never has to backtrack over it.
  3. Never compile a user-supplied pattern in a request path. If you must, run it somewhere you can kill: a Worker, a subprocess, a separate service.
  4. Use a non-backtracking engine for untrusted input. RE2 (and Go's regexp, and Rust's regex) guarantee linear time by giving up backreferences and lookarounds. That's usually a fine trade.
  5. Lint for it. safe-regex and eslint-plugin-security's detect-unsafe-regex catch the obvious shapes. JavaScript has no regex timeout — .NET and Java can bound execution, RegExp.prototype.exec cannot — so static detection is most of what you get.

The short version

Anchors decide whether you're validating or searching. Greediness decides where a match stops. Non-capturing groups keep your capture numbering stable across refactors. Lookarounds assert without consuming. And nested quantifiers over the same characters are the one construct worth treating as a bug on sight.

Everything else is lookup, which is what this page is for. Bookmark it, and next time that 180-character diff shows up, read it outside-in — anchors, then top-level alternation, then one nesting level at a time — instead of approving it because CI was green.

Cover photo by Godfrey Atima on Pexels.

References

Primary documentation and specifications checked when this article was last updated.

RegexDeveloper ToolsJavaScript

Related articles

All articles