NV ToolkitFormat · Validate · Convert
Reference

Regular Expressions Explained: A Practical Cheat Sheet With Tested Examples

A regular expression is a compact program that describes a set of strings. The syntax is dense, and most confusion comes down to a few rules: which characters are special, how a quantifier decides how much to consume, and where a match is allowed to begin. This cheat sheet works through those rules with examples that were executed in Node.js 22, so the outputs shown are the outputs you will get. Where JavaScript disagrees with Python or PCRE, the differences are collected at the end.

In short

Regex syntax breaks down into character classes, quantifiers, anchors, capturing groups and lookarounds, all modified by flags such as g, i, m, s, u and y. Quantifiers are greedy by default; appending ? makes them lazy. Nested quantifiers like (a+)+ can trigger catastrophic backtracking, so prefer a negated class such as [^>]+ wherever the pattern allows it.

By NaveenKumar T · Software engineer · builds and maintains NV Toolkit Published 2026-09-10 7 min read

Literals, character classes and quantifiers: greedy vs lazy

Most characters in a pattern match themselves: /cat/ matches the letters c, a, t in that order, anywhere in the input. A small set of characters carries special meaning and must be escaped with a backslash to be matched literally: . * + ? ^ $ | ( ) [ ] { } and the backslash itself (plus / inside a JavaScript regex literal). The dot is the one people forget. /file.txt/ also matches 'fileXtxt', because an unescaped dot means 'any character except a line terminator'.

A character class in square brackets matches exactly one character from a set. [aeiou] is any vowel, [a-z] is a range, and a leading caret negates: [^aeiou] is any single character that is not a vowel. Inside a class most metacharacters lose their meaning, so [.-] matches a literal dot or hyphen. Shorthand classes cover the common sets: \d is [0-9], \w is [A-Za-z0-9_], \s is whitespace, and the uppercase forms \D, \W and \S are their negations.

A quantifier follows a token and says how many times it may repeat: ? is zero or one, * is zero or more, + is one or more, and {n}, {n,} and {n,m} give exact counts or ranges. Quantifiers bind to the single preceding token, so /ab+/ means an 'a' followed by one or more 'b', not a repeated 'ab'; wrap a sequence in a group to repeat it as a unit.

Quantifiers are greedy: they consume as much input as they can and give back characters only when the rest of the pattern cannot otherwise match. Applied as <.+> to a string containing two HTML tags, the engine grabs everything from the first < to the last >, because .+ happily swallows the intermediate > characters and only the final one is needed to finish the match.

Appending ? to a quantifier makes it lazy: <.+?> stops at the first > that lets the whole pattern succeed, so each tag is matched on its own. A negated class such as <[^>]+> is usually the better fix. It cannot overshoot in the first place, needs no backtracking, and states the intent ('anything that is not a closing bracket') more clearly than a lazy dot.

javascript
"cat cot cut c9t".match(/c[aeiou]t/g)      // ["cat", "cot", "cut"]
"cat cot cut c9t".match(/c[^aeiou]t/g)     // ["c9t"]
"Order 66, item 1234".match(/\d+/g)        // ["66", "1234"]
"Total: $19.99".match(/\$\d+\.\d{2}/)[0]   // "$19.99"   (escaped $ and .)
"v1.2-beta".match(/[\d.-]+/)[0]            // "1.2-"     (. and - literal inside [])

["ac", "abc", "abbc", "abbbc"].map(s => /^ab?c$/.test(s))     // [true, true, false, false]
["ac", "abc", "abbc", "abbbc"].map(s => /^ab{2,3}c$/.test(s))  // [false, false, true, true]

const s = "<b>bold</b> and <i>italic</i>";
s.match(/<.+>/)[0]    // "<b>bold</b> and <i>italic</i>"   greedy: one huge match
s.match(/<.+?>/g)     // ["<b>", "</b>", "<i>", "</i>"]    lazy
s.match(/<[^>]+>/g)   // ["<b>", "</b>", "<i>", "</i>"]    negated class: same result, no backtracking

Anchors, word boundaries, groups and alternation

^ and $ anchor a match to the start and end of the input. Without them, /\d{3}/ happily finds three digits inside '1234'; /^\d{3}$/ requires the whole string to be exactly three digits. \b is a word boundary: the zero-width position between a \w character and a \W character, or the edge of the string. /\bcat\b/ matches the standalone word but not the 'cat' inside 'concatenate' or 'category'.

Parentheses group and capture. (\d{4})-(\d{2})-(\d{2}) captures year, month and day into match[1] through match[3]. A named group (?<year>\d{4}) captures into match.groups.year, and $<year> refers to it in a replacement string. A non-capturing group (?:...) groups without capturing, which keeps the numbering of later groups stable. A backreference \1 (or \k<name>) matches the same text the group already matched, which is how you find doubled words.

The pipe means 'or', and it has the lowest precedence of any operator: everything to its left is one alternative and everything to its right is the other. /^cat|dog$/ therefore means 'starts with cat' OR 'ends with dog', which is almost never what was intended. Write /^(?:cat|dog)$/ to anchor both alternatives.

javascript
"cat concatenate category".match(/\bcat\b/g)   // ["cat"]
"cat concatenate category".match(/cat/g)       // ["cat", "cat", "cat"]
/^\d{3}$/.test("1234")                         // false

const m = "2026-09-10".match(/^(?<year>\d{4})-(?<month>\d{2})-(?<day>\d{2})$/);
m.groups   // { year: "2026", month: "09", day: "10" }

"2026-09-10".replace(/(?<y>\d{4})-(?<m>\d{2})-(?<d>\d{2})/, "$<d>/$<m>/$<y>")
// "10/09/2026"

"hello hello world".match(/\b(\w+) \1\b/)[0]   // "hello hello"
/^cat|dog$/.test("category")                   // true   (starts with "cat")
/^(?:cat|dog)$/.test("category")               // false

Lookahead and lookbehind

Lookarounds assert that something is, or is not, adjacent to the current position without consuming it. (?=...) is positive lookahead, (?!...) negative lookahead, (?<=...) positive lookbehind and (?<!...) negative lookbehind. Because the asserted text is not consumed, it is not part of the match, which makes lookarounds ideal for extractions like 'the digits after a dollar sign' where you want the digits alone.

Several lookaheads at the start of a pattern act as independent conditions on the same string. The password rule 'at least eight characters, containing an uppercase letter and a digit' is /^(?=.*[A-Z])(?=.*\d).{8,}$/: each lookahead scans from position 0 and rewinds, then .{8,} enforces the length. JavaScript gained lookbehind in ES2018 and, unlike Python, allows the lookbehind to have variable width.

javascript
const t = "price: $42, cost: 17";
t.match(/\d+(?=,)/g)       // ["42"]   digits followed by a comma
t.match(/(?<=\$)\d+/g)     // ["42"]   digits preceded by "$"
t.match(/(?<!\$)\b\d+/g)   // ["17"]   digits not preceded by "$"

const strong = /^(?=.*[A-Z])(?=.*\d).{8,}$/;
strong.test("Passw0rdX")   // true
strong.test("password")    // false

"USD 100, EUR 20".match(/(?<=USD |EUR )\d+/g)   // ["100", "20"]  variable-width lookbehind

JavaScript flags: g, i, m, s, u, y (and d)

Flags change how the whole pattern behaves. g finds every match instead of stopping at the first. It also makes the regex object stateful: each test() or exec() call advances lastIndex, and the next call starts from there, so reusing one global regex across several strings gives alternating results. i ignores case. m makes ^ and $ match at line breaks rather than only at the ends of the input.

s (dotAll, ES2018) lets the dot match line terminators. u switches on Unicode mode: the pattern is read as code points rather than UTF-16 code units, so a dot matches an emoji as one character, \p{L} property escapes become available, and unrecognised escapes such as \A turn into syntax errors instead of silently matching a literal 'A'. y (sticky) requires the match to begin exactly at lastIndex, which is what a hand-written tokenizer needs. d (ES2022) records start and end indices for every group in match.indices.

FlagNameEffect
gglobalFinds every match; makes the regex stateful via lastIndex
iignoreCaseCase-insensitive matching
mmultiline^ and $ match at line breaks, not just the string ends
sdotAll. also matches line terminators
uunicodeReads code points; enables \p{...} property escapes
ystickyMatch must begin exactly at lastIndex
dhasIndicesRecords start/end indices for every group in match.indices
javascript
"one\ntwo\nthree".match(/^t\w+$/gm)   // ["two", "three"]
"one\ntwo\nthree".match(/^t\w+$/g)    // null   (without m, ^ and $ only match the string ends)
/a.b/.test("a\nb")                    // false
/a.b/s.test("a\nb")                   // true
/^.$/.test("😀")                      // false  (two UTF-16 code units)
/^.$/u.test("😀")                     // true
"Ünïcödé".match(/\p{L}+/u)[0]         // "Ünïcödé"

const g = /a/g;
[g.test("aa"), g.test("aa"), g.test("aa")]   // [true, true, false]  lastIndex: 1, 2, then reset to 0

const tok = /\s*(\d+|[+*])/y;
let m, out = [];
while ((m = tok.exec("3 + 4 * 5"))) out.push(m[1]);
out   // ["3", "+", "4", "*", "5"]

Real-world patterns, run against real inputs

Each pattern below was executed against the inputs shown — try your own in the regex tester. They are practical rather than exhaustive. The email pattern accepts anything shaped like something@something.something, which is the right strictness for a form field; the only real validation of an address is sending mail to it. The ISO date pattern checks digit layout and ranges but not the calendar, so it accepts 2026-02-30. If that matters, construct a Date from the string and compare it back.

The IPv4 pattern spells out each octet as 250–255, 200–249, 100–199, 10–99 or 0–9, which rejects 256 and leading zeros. The phone patterns are deliberately loose because real numbers vary too much for a strict regex: the US version accepts common punctuation and requires an area code that does not start with 0 or 1; the international version requires a + prefix, a one- to three-digit country code and six to fourteen further digits. The slug pattern forbids leading, trailing and doubled hyphens, and the slugify helper shows the usual way to produce one from a title.

javascript
const email = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
// ada@example.com → true   a.b+tag@sub.example.co.uk → true   no-at.example.com → false   x@y → false

const isoDate = /^\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\d|3[01])$/;
// 2026-09-10 → true   2026-13-01 → false   2026-9-1 → false   2026-02-30 → true (no calendar check)

const usPhone = /^(?:\+1[ -]?)?\(?([2-9]\d{2})\)?[ -]?(\d{3})[ -]?(\d{4})$/;
// (415) 555-2671 → true   +1 415 555 2671 → true   4155552671 → true   155-555-2671 → false

const intlPhone = /^\+(?:[1-9]\d{0,2})[ -]?\d(?:[ -]?\d){5,13}$/;
// +44 20 7946 0958 → true   +33 1 23 45 67 89 → true   020 7946 0958 → false

const ipv4 = /^(?:(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)\.){3}(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)$/;
// 192.168.1.1 → true   0.0.0.0 → true   256.1.1.1 → false   01.2.3.4 → false   1.2.3 → false

const hexColor = /^#(?:[0-9a-fA-F]{3}){1,2}$/;
// #fff → true   #1e90ff → true   #ffff → false   #12345g → false   fff → false

const slug = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
// hello-world → true   -bad → false   double--dash → false   Upper → false

const slugify = s => s.normalize("NFKD").replace(/[̀-ͯ]/g, "")
  .toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
slugify("  Café  au lait ")   // "cafe-au-lait"
slugify("C++ & Rust")         // "c-rust"

Catastrophic backtracking

JavaScript, Python, PCRE and Java use backtracking engines: they try one path through the pattern, and when it fails they step back and try another. Certain patterns make the number of paths grow exponentially with input length, so a failing match can take seconds or minutes. This is catastrophic backtracking, and when the pattern runs on user input it is a denial-of-service vector known as ReDoS. The classic shape is a quantified group whose contents are themselves quantified, such as (a+)+, or a repeated group whose alternatives can match the same text, such as (\w|\s?)*.

Measured in Node 22, /^(a+)+$/ against a run of a's followed by a character that breaks the match takes about 27 ms at 22 characters, 118 ms at 24, 498 ms at 26 and 1.8 seconds at 28: the time doubles with every extra character. The cure is to make it impossible for the engine to split the same text in more than one way. Use a single quantifier where one will do (/^a+$/ answers the same question in microseconds), replace nested quantifiers with a negated class, or put a mandatory separator between repetitions so adjacent groups cannot overlap: \w+(?:\s\w+)* for space-separated words. Capping input length before matching bounds the worst case. V8 short-circuits some well-known shapes, but not all of them, so do not rely on the engine to protect you.

javascript
// Node 22, /^(a+)+$/ against "a".repeat(n) + "!"  (can never match)
// n = 22 →  27 ms      n = 24 → 118 ms      n = 26 → 498 ms      n = 28 → 1805 ms

// Same question without the nested quantifier:
/^a+$/.test("a".repeat(28) + "!")                    // false in 0.03 ms

// Words separated by single spaces, written so repetitions cannot overlap:
/^\w+(?:\s\w+)*$/.test("word ".repeat(40) + "!")     // false in 0.05 ms
/^\w+(?:\s\w+)*$/.test("word word word")             // true

JavaScript vs Python and PCRE: the differences that bite

Named groups: JavaScript and PCRE accept (?<name>...); Python's re module accepts only (?P<name>...) and rejects the JavaScript form with 'unknown extension ?<'. JavaScript in turn rejects (?P<name>...) as an invalid group. In replacement strings JavaScript uses $1 and $<name>, while Python uses \1 and \g<name>.

Anchors and modes: \A and \Z (absolute start and end of the string) exist in Python and PCRE but not in JavaScript, where \A without the u flag silently matches a literal 'A'. Python spells dotAll as re.S and multiline as re.M; JavaScript uses the s and m flags. Inline flags like (?i) work in Python and PCRE; JavaScript rejects them, and the scoped modifier form (?i:...) only arrived with ES2025 (Node 23 and later; Node 22 throws 'Invalid group').

Lookbehind and atomicity: Python requires fixed-width lookbehind and fails on (?<=[a-z]+) with 'look-behind requires fixed-width pattern'; JavaScript allows any pattern. Possessive quantifiers (a++) and atomic groups (?>...) exist in PCRE and Python 3.11+, and are syntax errors in JavaScript. Finally, \w and \d are ASCII-only in JavaScript even with the u flag (use \p{L} and \p{Nd} for Unicode letters and digits), whereas Python's are Unicode-aware by default for str patterns.

python
# Python 3.12
import re
re.sub(r'(?P<y>\d{4})-(?P<m>\d{2})', r'\g<m>/\g<y>', '2026-09')   # '09/2026'
re.match(r'(?<y>\d{4})', '2026')            # re.error: unknown extension ?<y
re.search(r'(?<=[a-z]+)\d+', 'abc123')      # re.error: look-behind requires fixed-width pattern
re.search(r'a++b', 'aaab').group()          # 'aaab'  (possessive, 3.11+)
re.search(r'(?i)hello', 'HELLO').group()    # 'HELLO'
re.findall(r'\w+', 'Ünïcödé café')          # ['Ünïcödé', 'café']

# JavaScript, same ideas
"2026-09".replace(/(?<y>\d{4})-(?<m>\d{2})/, "$<m>/$<y>")   // "09/2026"
"Ünïcödé café".match(/\w+/gu)                // ["n", "c", "d", "caf"]  — ASCII only
"Ünïcödé café".match(/\p{L}+/gu)             // ["Ünïcödé", "café"]
new RegExp("(?P<y>\\d{4})")                  // SyntaxError: Invalid group
new RegExp("(?<=[a-z]+)\\d+").exec("abc123") // ["123"]  — variable-width lookbehind is fine
How this was tested: Every pattern, flag effect and backtracking timing in this guide was run in Node.js 22.22 on 2026-09-10.

Sources

  1. Regular expressions — JavaScript | MDN
  2. ECMAScript® 2026 Language Specification — RegExp (Regular Expression) Objects

Frequently asked questions

Should I validate email addresses with a regex?

Only loosely. The full grammar in RFC 5322 allows quoted local parts, comments and IP-literal domains that no simple pattern covers, and a strict pattern rejects real addresses while still accepting undeliverable ones. Check for the something@something.something shape to catch typos, then confirm the address by sending to it.

Why does test() return true, then false, on the same string?

The regex has the g (or y) flag, which makes it stateful. Each successful test() moves lastIndex past the match, and the next call resumes from there; when it fails, lastIndex resets to 0 and the cycle starts again. Drop the flag for one-off tests, or set regex.lastIndex = 0 before reusing it.

What is the difference between match, matchAll, exec and test?

test() returns a boolean. exec() returns one match with groups and index, and with the g flag can be called repeatedly to walk through the string. String.prototype.match returns the first match with groups when the regex has no g flag, and a plain array of all matched strings (no groups) when it does. matchAll() requires g and returns an iterator of full match objects, which is the cleanest way to get every match with its groups.

How do I match a literal dot, slash or backslash?

Escape it: \. for a dot, \/ for a slash inside a regex literal, and \\ for one backslash. When building a pattern from a string with new RegExp, remember the string literal consumes one level of escaping, so a single backslash in the pattern is written as two in the source. Escape user-supplied text with s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') before inserting it.

Can I parse HTML or JSON with regular expressions?

Not reliably. Both formats nest arbitrarily deep, and regular expressions cannot count matching brackets. A regex is fine for pulling one attribute out of a known snippet; for anything structural use DOMParser or JSON.parse, which also handle the escaping rules a pattern would get wrong.

Paste a pattern and sample text into the Regex Tester to see every match, group and flag effect highlighted as you type.

Test a pattern

Keep working

Related tools