Regex Basics with Worked Examples: From PIN Codes to Catastrophic Backtracking
Regular expressions look like line noise until you see that they're built from a handful of small pieces. Once you know those pieces, most everyday patterns (postcodes, dates, phone numbers, log lines) take a few minutes to write and test. This guide teaches the building blocks through worked examples, every one of which you can paste into the Regex Validator and watch match. It also covers the two mistakes that cause most regex bugs: greedy matching and catastrophic backtracking.
The building blocks
| Piece | Meaning | Example |
|---|---|---|
abc | Literal characters | cat matches "cat" |
. | Any one character except a newline | c.t matches "cat", "cot", "c9t" |
[abc] | One character from the set | [aeiou] matches one vowel |
[^abc] | One character not in the set | [^0-9] matches one non-digit |
\d \w \s | Digit, word character, whitespace | \d\d matches "42" |
* + ? | 0 or more, 1 or more, 0 or 1 | colou?r matches both spellings |
{n,m} | Between n and m repetitions | \d{2,4} matches 2 to 4 digits |
^ $ | Start and end of the string (or line, with the m flag) | ^\d+$ means digits only |
\b | Word boundary | \bcat\b skips "concat" |
( ) | Group and capture | (\d{4})-(\d{2}) captures year and month |
a|b | Either a or b | jpe?g|png matches three extensions |
Characters with special meaning, such as ., *, +, ?, (, ), [, {, ^, $, | and \, need a backslash to be matched literally. To match a real full stop, write \.. Forgetting this is common: example.com as a pattern also matches "exampleXcom".
Worked example 1: an Indian PIN code
A PIN code is six digits and doesn't start with zero. Build it piece by piece:
- First digit 1 to 9:
[1-9] - Then exactly five more digits:
[0-9]{5} - Nothing before or after: wrap it in
^and$.
"560001" matches. "056001" doesn't, because the first digit is 0. "5600011" doesn't, because $ demands the string end after six digits. Without the anchors, the pattern would happily find "560001" inside "PIN: 5600011", which is a classic source of validation bugs. When you're validating a whole field, anchor both ends.
Worked example 2: an ISO date, and its limits
Read it left to right: four digits, a hyphen, a month from 01 to 12, a hyphen, and a day from 01 to 31. The month part uses alternation: either 0 followed by 1 to 9, or 1 followed by 0 to 2. The day part has three branches for 01 to 09, 10 to 29 and 30 to 31.
Now test it with "2026-02-31". It matches. There's no 31 February, but the regex doesn't know that, because a regular expression checks shape, not meaning. You could write a much longer pattern that handles month lengths, and leap years make it worse. The practical approach is to use the regex to check the format, then parse the date in code and check that it's real.
Worked example 3: capturing and reusing parts
Parentheses capture what they match, so you can pull pieces out or reuse them in a replacement. Converting "2026-10-30" into "30/10/2026" in JavaScript:
"2026-10-30".replace(/(\d{4})-(\d{2})-(\d{2})/, "$3/$2/$1")
// \u2192 "30/10/2026"Group 1 is the year, group 2 the month, group 3 the day. Named groups make longer patterns readable: (?<year>\d{4})-(?<month>\d{2}) lets you refer to year and month by name. If you need parentheses only for grouping, not capturing, write (?:...). It keeps the group numbers tidy.
Greedy versus lazy
Quantifiers like * and + are greedy by default. They grab as much as they can and only give characters back if the rest of the pattern fails. Try <.+> against the text <b>bold</b> text. You might expect it to match <b>. It actually matches <b>bold</b>, because .+ runs to the end of the string and then backs up only as far as the last >.
Adding ? after a quantifier makes it lazy: <.+?> takes as little as possible, so with the g flag it matches <b> and </b> separately. Often an even better fix is a negated class: <[^>]+> means "a <, then anything that isn't >, then >", which can't overshoot and doesn't need to backtrack.
Don't parse HTML with regex
Patterns like <[^>]+> are fine for quick search-and-replace in a text editor. For real HTML, with nested tags, attributes containing > and comments, use an HTML parser. Regex can't reliably match nested structures.
Flags change the rules
- The g (global) flag finds every match, not just the first.
- The i (ignore case) flag makes
colou?rmatch "Color", "colour" and "COLOR". - The m (multiline) flag makes
^and$match at the start and end of each line, not just the whole string. Without m,^\d+$fails on "12\nabc". With gm it finds "12" and also "34" in "12\nabc\n34". - The s (dotAll) flag lets
.match newlines too. - The u (unicode) flag treats the pattern as Unicode code points, so emoji and other characters outside the basic plane count as one character.
The Regex Validator starts with the g and i flags turned on. That's convenient for searching, but it means a pattern will match regardless of case until you remove i. If you're building a case-sensitive validator, clear that flag before testing.
Catastrophic backtracking
Some patterns are correct but so slow on certain input that they can freeze a browser tab or a server. The classic example is a nested quantifier: ^(a+)+$.
Give it 25 letter a's followed by "!". The match must fail because of the "!", but before giving up, the engine tries every way of splitting the a's between the inner a+ and the outer +. There are 2ⁿ⁻¹ ways to split n characters into groups, so for 25 characters that's 2²⁴ = 16,777,216 combinations. Each extra "a" doubles the work. This is the basis of ReDoS (regular expression denial of service) attacks, where a crafted input makes a server spend seconds or minutes on a single regex.
Warning signs are nested quantifiers like (a+)+ or (\w*)*, and alternations whose branches can match the same text, like (\d|\d\d)+. The fix is usually to make the pattern unambiguous: ^a+$ matches exactly the same strings as ^(a+)+$ without any of the risk.
Not every regex engine is the same
The validator uses your browser's JavaScript engine. Most basic syntax works the same in Python, PHP, Java and Go, but details differ, and those details cause bugs when a pattern moves between languages:
- \d isn't always ASCII. In JavaScript,
\dmatches only 0 to 9, even with the u flag; an Arabic-Indic digit like "٣" doesn't match. In Python 3,\don a normal string matches any Unicode decimal digit, including that one. - Named groups are written differently. JavaScript uses
(?<name>...). Python'sremodule traditionally uses(?P<name>...). - Possessive quantifiers and atomic groups (
a++,(?>...)) exist in PCRE and Java but not in JavaScript. - Go's RE2 engine has no backreferences or lookarounds, by design, in exchange for guaranteed linear time. It's immune to the backtracking problem above.
A testing habit that catches bugs
- Write down three strings that must match and three that must not, before writing the pattern.
- Include the awkward cases: empty string, leading or trailing spaces, the value embedded in a longer string, and the longest realistic input.
- Paste all six into the validator and confirm the highlights are exactly what you expect.
- If the pattern has nested quantifiers, test a long string that almost matches but fails at the end.
Limitations
- The validator tests with the browser's built-in engine. A pattern that works here can behave differently in Python, Java or a database.
- A regex checks shape, not meaning. No regex can tell you an email address has a working inbox or that a date exists on the calendar. Validate those in code.
- Slow patterns run on your device. A catastrophically backtracking pattern can make the page unresponsive while it runs. Close the tab if it hangs.
- Readability decays. A 200-character pattern is hard for the next person to maintain. If it's getting that long, a few lines of ordinary code may be clearer.
Test every example above
Paste a pattern and some sample text, toggle flags, and see each match highlighted as you type.