KeyboardWarrior.co.ukclient-side utility arcade

Regular expressions

Regular expressions: patterns, groups, and the traps

How a JavaScript regular expression matches: classes, anchors, groups, quantifiers, and the greedy, lastIndex, and newline mistakes that take the wrong text.

9 min read

A regular expression walks a string from the left and reports the first span that fits. The earliest start wins. At that start, the left branch of an | wins, and a greedy quantifier takes as many characters as the rest of the pattern will still allow. Flags change the alphabet and the meaning of ^ and $. This note uses JavaScript's RegExp, the same dialect as the regex tester. grep, Python, and an editor's search share the punctuation and disagree on the edges.

The earliest match

JavaScript
const line = "WARN order=10483 status=failed";
const match = line.match(/order=(\d+)/);

Without the g flag, match returns that first hit and stops. Element 0 is the whole match, order=10483. Element 1 is the first capturing group, 10483. index is where it started. order= is literal text. \d is the digits 0 to 9 only. A full-width digit does not count. \p{Nd} with the u flag means any Unicode decimal digit.

JavaScript
/\d/.test("5");       // false
/\p{Nd}/u.test("5");  // true

\w is [A-Za-z0-9_]. Accented letters sit outside it, so a "word" pattern stops mid-word:

JavaScript
"fiancé".match(/\w+/)[0];  // "fianc"
"déjà vu".match(/\w+/g);   // ["d", "j", "vu"]

\p{L} with the u flag means any letter. "déjà vu".match(/\p{L}+/gu) returns ["déjà", "vu"]. Use that for prose. Use \w for an identifier that really is ASCII.

\s is whitespace: space, tab, the ASCII line breaks, and a set of Unicode spaces, including the non-breaking space \u00a0. \S is one character that is not whitespace. \S+ runs through letters, punctuation, and é, and stops at a space. It does not stop at @ or a full stop, so it splits a log field rather than checking an address.

Classes and the dot

. is one character, and that character is not a line break unless the s flag (dotAll) is set. /a.b/ matches axb and fails when a newline sits between a and b. /a.b/s matches across that newline. [^\n] writes the same limit into the pattern, so deleting the flag later does not change it.

Inside a class, most punctuation is already literal. [.*+?] is a dot, a star, a plus, or a question mark. The characters that stay special are \, a leading ^ (it negates the class), a ] that is not first, and a - between two characters, where it forms a range. Put a literal hyphen first or last, or escape it.

[A-z] looks like "letters". It is every ASCII character from A through z, including [, \, ], ^, _, and the backtick. [A-Za-z] is the class that was meant. A class is one character however long it looks. [\d.] is a digit or a full stop. The quantifier goes outside: [\d.]+.

JavaScript strings are UTF-16. The emoji 👍 has .length of 2, one character stored as a surrogate pair. Without u, /./ matches only the first code unit. With u, /./u matches the whole character. Turn u on for text you did not generate. \p{...} requires u, or the newer v flag, and an unknown escape is then a syntax error instead of a silent literal.

Anchors and the trailing newline

^ matches the start of the string. $ matches the end of it. JavaScript does not treat a final newline as optional. Python does.

JavaScript
/^paid$/.test("paid");    // true
/^paid$/.test("paid\n");  // false

A value from readFileSync, or from a textarea, often still has the newline the editor saved. Trim the end, then anchor, when the whole string must match:

JavaScript
/^paid$/.test("paid\n".trimEnd());       // true
/^paid$/.test("paid\nextra".trimEnd());  // false

trimEnd only removes whitespace at the end. The second string still has a newline in the middle, so $ refuses it.

The m flag lets ^ and $ match at every line break. That suits a search through a log. On a validator it accepts the first line and ignores the rest:

JavaScript
/^paid$/m.test("paid\nextra");  // true

$ succeeded at the newline after paid. extra never had to fit. Multiline also does not let the dot cross a line. "ab\ncd".match(/^.*$/m) is ab. Add s only when a dot should cross a break, and expect it to cross every break.

\b is a position between a \w character and a character outside \w. \bcat\b matches cat and the cat. and does not match category. _ counts as a word character, so \bfoo\b does not match inside foo_bar. Accents are outside \w, so the boundary fires early, and a pattern of the whole word fails to find a boundary after é:

JavaScript
"fiancé".match(/\bfianc\b/)[0];  // "fianc"
/\bfiancé\b/.test("fiancé");     // false

\p{L}+ with u matches fiancé as one word. (?<!\p{L}) and (?!\p{L}), also with u, are the edges that follow letters rather than ASCII. \b is the right edge for ASCII identifiers and the wrong edge for names.

Quantifiers and alternation

* is zero or more, + is one or more, ? is zero or one, {2,4} is from two to four. They are greedy. The engine takes the longest run that still lets the rest of the pattern match, then gives characters back one at a time if it must.

JavaScript
const html = "<a>one</a> <b>two</b>";
html.match(/<.+>/)[0];  // "<a>one</a> <b>two</b>"

.+ runs to the last >, the longest run that still leaves a > for the pattern. A ? after a quantifier makes it lazy: as few characters as possible, then grow if the rest fails.

JavaScript
html.match(/<.+?>/)[0];  // "<a>"
html.match(/<.+?>/g);    // ["<a>", "</a>", "<b>", "</b>"]

</a> also begins with < and ends with >. A class is tighter. /<[a-z][^>]*>/g returns ["<a>", "<b>"] on this string. An attribute containing > still ends it early, which is why this is not an HTML parser.

Quoted fields have the same shape.

JavaScript
const fields = '"ab" "cd"';
fields.match(/".*"/)[0];    // '"ab" "cd"'
fields.match(/"[^"]*"/)[0]; // '"ab"'

The greedy dot may consume quotes, so one match runs from the first quote to the last. [^"] stops at the next quote. Lazy ".*?" agrees on this string. Put a newline inside the first field and they split: without s the dot cannot cross it, so the match slips forward to a later pair of quotes, while [^"] still takes the whole field. Use [^"\n] when a field must stay on one line.

Alternation is tried from the left, at the current position. It is not longest-match.

JavaScript
"catch".match(/cat|catch/)[0];   // "cat"
"catch".match(/catch|cat/)[0];   // "catch"
"https".match(/http|https/)[0];  // "http"
"https".match(/https?/)[0];      // "https"

https? is http plus an optional s. As http|https, the left branch fits as soon as http does, and the engine never tries https there. The leftover s stays unmatched, and the match still succeeds. Put the longer branch first when two branches share a prefix.

A quantifier on a group that itself contains a quantifier of the same characters, followed by a tail the string does not have, makes the engine try every split. The page stops responding. JavaScript has no atomic group to cut that short. Keep one quantifier on one class. If the pattern is built from a search box, cap the length of that text before you compile it.

Groups, replace, and lastIndex

Parentheses group, so a quantifier or | can apply to several characters, and by default they capture. (?:...) groups without a capture, so later numbers stay put. Numbers follow each opening parenthesis from the left. (a(b)) is group 1 wrapping group 2. (?<order>\d+) keeps its number and also appears as match.groups.order. Read the name. The number moves the next time a parenthesis is inserted on the left.

JavaScript
const row = "order=10482 user=ada@example.com amount=12.50";
const found = row.match(
  /order=(?<order>\d+) user=(?<email>\S+@\S+) amount=(?<amount>\d+\.\d{2})/,
);
found.groups.order;   // "10482"
found.groups.email;   // "ada@example.com"
found.groups.amount;  // "12.50"

Inside the pattern, \1 is a numbered backreference and \k<order> is a named one. In a replacement string JavaScript uses $1, $<order>, and $$ for a literal dollar. \1 in the replacement is not group 1.

JavaScript
row.replace(/order=(?<order>\d+)/, "id=$<order>");
// "id=10482 user=ada@example.com amount=12.50"

replace without g changes the first match only. replaceAll throws if the regular expression lacks g.

A global expression stores the resume point on lastIndex. A miss writes it back to 0, so three tests of one string alternate:

JavaScript
const re = /order=(\d+)/g;
re.test("order=1");  // true, lastIndex sits past the match
re.test("order=1");  // false, the search started at the end
re.test("order=1");  // true, the miss reset lastIndex

Build the expression where you use it, or set lastIndex = 0 before each search. One shared /g object is enough to make a second call look empty.

match with g drops the groups. matchAll keeps them, and it requires g.

JavaScript
const text = "order=1; order=2";
text.match(/order=(\d+)/g);  // ["order=1", "order=2"]
[...text.matchAll(/order=(\d+)/g)].map((hit) => hit[1]);  // ["1", "2"]

An optional group that did not participate is undefined, not "". "x".match(/x(\d+)?/)[1] is undefined, and the match still succeeds. If a missing column should reject the line, delete the ?.

One log line, end to end

The line below is the shape a service writes. readEntry returns the timestamp, level, order, email, and amount, or null when a piece is missing or the money is not two decimal places.

text
2026-11-08T09:14:02Z INFO order=10482 user=ada@example.com amount=12.50 GBP status=paid
JavaScript
const entry =
  /^(?<time>\d{4}-\d{2}-\d{2}T[\d:]+Z) (?<level>INFO|WARN) order=(?<order>\d+) user=(?<email>\S+@\S+) amount=(?<amount>\d+\.\d{2}) GBP status=(?<status>paid|failed)$/;

function readEntry(line) {
  const found = entry.exec(line.trimEnd());
  if (!found) return null;
  return found.groups;
}

^ is the start. The time group fits 2026-11-08T09:14:02Z. The clock half is only digits and colons, so 99: would also fit. INFO|WARN is safe because neither word prefixes the other. The amount needs a full stop and exactly two digits: 12.50 matches, 12.5 and 12 do not. The literal GBP stops a bare number in another currency. trimEnd removes one trailing newline, which $ would otherwise reject. status=pending, or any word after paid, returns null.

exec returns null on a miss. Reading .groups on that null throws, often from a later function. The check belongs in readEntry.

There is no g flag, so exec does not move lastIndex and the next line is a fresh search. Adding g to scan a whole buffer makes the second line start where the first left off. Split the buffer into lines and keep this pattern.

grep, Python, and new RegExp

grep without -E is basic syntax. +, ?, and parentheses are literal until you escape them. grep -E 'order=[0-9]+' is the form that resembles JavaScript. Neither understands \d. [0-9] travels. [[:digit:]] means a digit in grep -E. In JavaScript it is a class of the characters in :digit, followed by a literal ]. It matches d] and does not match 5.

grep strips the line break and then tests, so $ is the end of the line. The same text pointed at a whole buffer in JavaScript, newlines still attached, is a different program. Python is a third reading: ^paid$ matches "paid\n" there, and it does not in JavaScript. Trim, and say that you trimmed, when both sides must agree.

Put a fixed shell pattern in single quotes. Double quotes let the shell eat backslashes before grep sees them. sed replacements use \1. JavaScript uses $1. Both strings are legal in the other tool, so the command succeeds and writes the wrong text.

new RegExp compiles a string the string parser has already processed. "\\d+" is the digit pattern. "\d+" is the pattern d+, because \d is not a string escape and the backslash disappears.

JavaScript
new RegExp("\\d+").test("10482");  // true
new RegExp("\d+").test("10482");   // false
new RegExp("\d+").test("ddd");     // true

Prefer a /.../ literal when the pattern is fixed. Escape metacharacters that arrived from outside: \ ^ $ . * + ? ( ) [ ] { } |. A search for the filename file.txt, interpolated raw, also matches fileXtxt, because . means any character.

Flags, and the strings that should fail

g finds every match and arms lastIndex. Leave it off a validator. i folds case, so leave it off field names. It is not Unicode casefolding: /i/i and /i/iu both fail to match İ (U+0130, Turkish capital I with a dot). s and m are independent, as the anchor section showed. v (unicodeSets) is the newer Unicode mode. A pattern that needs v throws on a runtime that only has u, so stay on u when the pattern has to run on an older engine.

The regex tester wants the pattern without slashes, and the flags in the flags field. Keep a failing line beside the one that should pass. For readEntry that set is: empty string, a trailing newline (pass, because of trimEnd), a trailing newline plus another word (fail), amount=12.5, status=pending, and a second order on the same line if you ever add g.

A pattern you have only seen succeed is unfinished. Regular expressions describe a flat shape you chose: a log line, a version number, a filter in an editor. HTML, JSON, and an email address as specified want a parser. Flat input wants a class, both anchors on a validator, and a global flag that is not hiding on a shared object.