Regular Expressions: Practical Guide with 20 Ready-to-Use Patterns
Regular expressions are one of those tools that every developer uses but few truly master. A well-crafted regex can replace 50 lines of string manipulation code. A poorly crafted one can bring your server to its knees. This guide covers the fundamentals and gives you 20 production-ready patterns you can use immediately.
What Are Regular Expressions?
A regular expression (regex) is a sequence of characters that defines a search pattern. At its core, a regex engine scans a string and reports whether the pattern matches, and optionally where and how many times.
Two major flavors exist in practice:
- PCRE (Perl Compatible Regular Expressions) - used by PHP, Python, Ruby, and most modern languages. Supports lookaheads, lookbehinds, named groups, and backreferences.
- POSIX - older standard used in Unix tools like
grep,sed, andawk. Less capable, no lookaheads, but widely available.
When to use regex:
- Validating input format (email, phone, postal code)
- Extracting structured data from unstructured text
- Search-and-replace with patterns
- Parsing log files and configuration formats
When NOT to use regex:
- Parsing HTML or XML (use a proper DOM parser)
- Parsing JSON (use a JSON library)
- Any recursive or deeply nested structure
- When a simple
string containsorsplitwill do the job
Basic Syntax
A regex pattern is a sequence of literals and metacharacters. Literals match themselves - the pattern cat matches the string "cat" exactly.
Metacharacters have special meaning: . * + ? ^ $ { } [ ] | ( ) \
To match a metacharacter literally, escape it with a backslash: \. matches a literal dot, \( matches a literal parenthesis.
Pattern: hello\.world
Matches: "hello.world"
No match: "hello_world"
Character Classes
A character class matches one character from a defined set.
| Syntax | Description | Example |
|---|---|---|
[abc] |
Any of a, b, or c | [aeiou] matches any vowel |
[a-z] |
Range: any lowercase letter | [a-zA-Z] matches any letter |
[^abc] |
Negated: any character NOT in the set | [^0-9] matches any non-digit |
. |
Any character except newline | a.c matches "abc", "a1c" |
Shorthand classes (available in PCRE):
| Class | Equivalent | Description |
|---|---|---|
\d |
[0-9] |
Any digit |
\D |
[^0-9] |
Any non-digit |
\w |
[a-zA-Z0-9_] |
Any word character |
\W |
[^a-zA-Z0-9_] |
Any non-word character |
\s |
[ \t\n\r\f\v] |
Any whitespace |
\S |
[^ \t\n\r\f\v] |
Any non-whitespace |
Anchors
Anchors do not match characters - they match positions in the string.
| Anchor | Position |
|---|---|
^ |
Start of string (or start of line in multiline mode) |
$ |
End of string (or end of line in multiline mode) |
\b |
Word boundary (between \w and \W) |
\B |
Non-word boundary |
Pattern: ^\d{3}$
Matches: "123" (exactly 3 digits, nothing else)
No match: "1234", "abc123"
Pattern: \bcat\b
Matches "cat" in "the cat sat" but not in "concatenate"
Quantifiers
Quantifiers specify how many times a preceding element must match.
| Quantifier | Meaning |
|---|---|
* |
0 or more |
+ |
1 or more |
? |
0 or 1 (optional) |
{n} |
Exactly n times |
{n,} |
n or more times |
{n,m} |
Between n and m times (inclusive) |
Greedy vs. Lazy:
By default, quantifiers are greedy - they match as much as possible. Add ? to make them lazy - they match as little as possible.
Input: "<b>bold</b> and <i>italic</i>"
Greedy: <.+> matches "<b>bold</b> and <i>italic</i>" (entire string)
Lazy: <.+?> matches "<b>", then "</b>", then "<i>", then "</i>"
Groups and Capturing
Parentheses group patterns and capture matched text for later use.
| Syntax | Type | Description |
|---|---|---|
(abc) |
Capturing group | Matches and captures "abc" |
(?:abc) |
Non-capturing group | Matches but does not capture |
(?P<name>abc) |
Named group (PCRE) | Captures into a named reference |
(?<name>abc) |
Named group (ECMA) | Same, JavaScript syntax |
Backreferences let you reference a previously captured group within the same pattern:
Pattern: (\w+)\s+\1
Matches: "hello hello" (the same word repeated)
No match: "hello world"
Non-capturing groups (?:...) are preferred when you need grouping for quantifiers or alternation but do not need to reference the captured value - they are slightly faster and keep group numbering clean.
Alternation
The pipe | acts as an OR operator between alternatives.
Pattern: cat|dog|bird
Matches: "cat", "dog", "bird"
Pattern: gr(a|e)y
Matches: "gray" and "grey"
Order matters. The engine tries alternatives left-to-right and stops at the first match. Put more specific alternatives before more general ones.
Pattern: colou?r|colour
The second alternative "colour" can never match because "colou?r" already covers it.
Better: colour|color or simply colou?r
Lookahead and Lookbehind
Lookarounds are zero-width assertions - they check for a pattern without consuming characters.
| Syntax | Type | Description |
|---|---|---|
(?=...) |
Positive lookahead | Matches if followed by ... |
(?!...) |
Negative lookahead | Matches if NOT followed by ... |
(?<=...) |
Positive lookbehind | Matches if preceded by ... |
(?<!...) |
Negative lookbehind | Matches if NOT preceded by ... |
Practical examples:
\d+(?= dollars)
Matches the number in "100 dollars" but not in "100 euros"
(?<=\$)\d+
Matches digits preceded by a dollar sign: "500" in "$500"
\b\w+\b(?!\s+is)
Matches a word NOT followed by " is"
(?<!\d)\d{4}(?!\d)
Matches exactly 4-digit numbers not adjacent to other digits
Flags / Modifiers
Flags change how the entire pattern is interpreted.
| Flag | Name | Effect |
|---|---|---|
i |
Case insensitive | [a-z] also matches [A-Z] |
g |
Global | Find all matches, not just the first (JS/Python) |
m |
Multiline | ^ and $ match start/end of each line |
s |
Dotall | . matches newlines too |
x |
Extended/Verbose | Allows whitespace and comments in pattern |
In PHP, flags go inside the delimiter: /pattern/im. In Python, they are passed as constants: re.IGNORECASE | re.MULTILINE. In JavaScript, they follow the closing slash: /pattern/gim.
The x flag is especially useful for complex patterns:
$pattern = '/
^ # start of string
(\d{4}) # year
- # separator
(\d{2}) # month
- # separator
(\d{2}) # day
$ # end of string
/x';
20 Practical Patterns
Test these patterns in your browser as you read. Copy them with care, too - the most-copied identifier pattern on the internet, the UUID regex that silently rejects UUID v7, is a reminder that a pattern outlives the spec it was written against.
| # | Name | Pattern | Matches |
|---|---|---|---|
| 1 | Email (simple) | ^[\w.+-]+@[\w-]+\.[a-zA-Z]{2,}$ |
user@example.com |
| 2 | Email (RFC-ish) | ^[a-zA-Z0-9.!#$%&'*+/=?^_\`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*\.[a-zA-Z]{2,}$ |
RFC 5321 subset |
| 3 | URL (http/https) | ^https?://[\w-]+(\.[\w-]+)+([\w.,@?^=%&:/~+#-]*[\w@?^=%&/~+#-])?$ |
https://example.com/path?q=1 |
| 4 | IPv4 address | ^((25[0-5]|2[0-4]\d|[01]?\d\d?)\.){3}(25[0-5]|2[0-4]\d|[01]?\d\d?)$ |
192.168.0.1 |
| 5 | IPv6 (simplified) | ^([0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}$ |
2001:0db8:85a3:0000:0000:8a2e:0370:7334 |
| 6 | Date YYYY-MM-DD | ^\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\d|3[01])$ |
2026-02-22 |
| 7 | Date DD/MM/YYYY | ^(0[1-9]|[12]\d|3[01])/(0[1-9]|1[0-2])/\d{4}$ |
22/02/2026 |
| 8 | Time HH:MM:SS | ^([01]\d|2[0-3]):[0-5]\d:[0-5]\d$ |
14:30:00 |
| 9 | ISO 8601 datetime | ^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?(Z|[+-]\d{2}:\d{2})$ |
2026-02-22T10:00:00Z |
| 10 | Phone E.164 | ^\+[1-9]\d{6,14}$ |
+14155552671 |
| 11 | Credit card (basic) | ^\d{13,19}$ |
4111111111111111 (Luhn not checked) |
| 12 | US ZIP code | ^\d{5}(-\d{4})?$ |
90210, 90210-1234 |
| 13 | Hex colour | ^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$ |
#fff, #1a2b3c |
| 14 | Password strength | ^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])[A-Za-z\d@$!%*?&]{8,}$ |
Min 8 chars, upper, lower, digit, special |
| 15 | URL slug | ^[a-z0-9]+(?:-[a-z0-9]+)*$ |
my-article-title (see slug rules) |
| 16 | Semantic version | ^\d+\.\d+\.\d+(-[a-zA-Z0-9.]+)?(\+[a-zA-Z0-9.]+)?$ |
1.2.3, 2.0.0-beta.1 |
| 17 | UUID v4 | ^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$ |
f47ac10b-58cc-4372-a567-0e02b2c3d479 |
| 18 | HTML tag (simple) | <([a-zA-Z][a-zA-Z0-9]*)\b[^>]*>(.*?)</\1> |
<div class="x">text</div> |
| 19 | CIDR notation | ^((25[0-5]|2[0-4]\d|[01]?\d\d?)\.){3}(25[0-5]|2[0-4]\d|[01]?\d\d?)/([12]?\d|3[0-2])$ |
192.168.0.0/24 |
| 20 | Markdown link | \[([^\]]+)\]\((https?://[^\)]+)\) |
[text](https://example.com) |
Pattern 4 (IPv4) uses
|as alternation - when testing, the surrounding parentheses ensure correct grouping. Use without escaping the pipe in your regex engine.
Performance Tips
Catastrophic Backtracking
The most dangerous regex mistake is a pattern that causes exponential backtracking. The classic example:
Pattern: (a+)+$
Input: "aaaaaaaaaaaaaaaaaab"
The engine tries every possible way to partition the a characters among the nested groups before concluding there is no match. On a 20-character input this can take seconds; on 30 characters, minutes. This failure mode has taken down Stack Overflow and Cloudflare in production — I dissect both incidents, the detection tests and the engine-level fixes in catastrophic backtracking and ReDoS.
Rules to avoid it:
- Never nest quantifiers over the same character class:
(a+)+,(\w+\s*)+ - Use atomic groups
(?>...)or possessive quantifiers++,*+if your engine supports them (PCRE does) - Prefer character classes over
.when you know what characters to expect - Anchor patterns whenever possible with
^and$
Possessive Quantifiers and Atomic Groups (PCRE)
// Greedy (can backtrack):
/\w+:/
// Possessive (no backtracking - match and keep):
/\w++:/
// Atomic group (equivalent):
/(?>)\w+):/
General Guidelines
- Compile regex once and reuse (in PHP, store in a static variable or a service; in Python, use
re.compile()) - Use
[0-9]when you mean ASCII digits —\dmeans something different in every language, as the next section measures - Use non-capturing groups
(?:...)when you do not need the captured value - Test edge cases: empty string, very long input, input that almost matches
Unicode Is Where Patterns Quietly Break
Every shorthand class in this article — \d, \w, \s — has a different meaning depending on which language you are in and which flags are set. Nothing warns you. I ran the same three checks through PHP 8.5.8, Python 3.14.6 and Node v26.5.0:
^\d+$ against ٤٢ (Arabic-Indic 42) |
Result |
|---|---|
PHP, no /u |
no match |
PHP, /u |
match |
| Python 3, default | match |
Python 3, re.ASCII |
no match |
| JavaScript, any flags | no match |
Three runtimes, three defaults. Python treats \d as Unicode-aware for str patterns unless you opt out; JavaScript's \d is permanently [0-9] and no flag changes it; PHP is ASCII-only until you add /u, which switches the shorthand classes to Unicode properties along with UTF-8 mode.
The failure that actually reaches production is simpler than any of that, and it is a validation pattern rejecting a real person's name:
preg_match('/^[a-z]+$/u', 'josé'); // 0 — the é is not in a-z
preg_match('/^\p{L}+$/u', 'José'); // 1 — any Unicode letter
[a-z] is a literal range of 26 code points. It has never meant "letters" and it never will. When you mean letters, say so with a Unicode property escape:
| Escape | Matches |
|---|---|
\p{L} |
any letter, any script |
\p{Lu} / \p{Ll} |
uppercase / lowercase letter |
\p{N} |
any numeric character |
\p{Script=Cyrillic} |
letters of one specific script |
\P{L} |
the negation — anything that is not a letter |
These need /u in PHP and the u (or v) flag in JavaScript, where they arrived with ES2018. Python's re does not support \p{...} at all — you need the third-party regex module for that, which is the single most common reason to reach outside the standard library.
Two more Unicode facts worth carrying:
. counts code points, not characters. The family emoji 👨👩👧👦 is seven code points — four emoji joined by three zero-width joiners — so ^.$ will not match it and .{1,10} length limits behave in ways your users will report as bugs. PCRE has \X for an extended grapheme cluster, which is what a human means by "one character".
Invalid UTF-8 makes preg_match fail rather than not match. With /u, feeding it a malformed byte sequence returns false, not 0 — which matters more than it sounds, for the reason in the API traps section below.
The $ Anchor Is Not the End of the String
Anchoring a validation pattern with ^...$ feels airtight. In two of the three runtimes it is not: $ matches at the end of the subject or immediately before a final newline.
^\d+$ against "123\n" |
Result |
|---|---|
| PHP 8.5.8 | match |
| Python 3.14.6 | match |
| Node v26.5.0 | no match |
So /^\d+$/ accepts "123\n" in PHP and Python. For a numeric field that is harmless. For anything that gets concatenated into a header, a log line, a shell command or a generated file, a trailing newline that passed validation is the first half of an injection, and the pattern that was supposed to prevent it reported success.
The fixes differ per language, and the naming is genuinely treacherous:
| Intent | PHP / PCRE | Python | JavaScript |
|---|---|---|---|
| End of subject, allowing final newline | $ |
$ |
— |
| Absolute end of subject | \z or the D modifier |
\Z |
$ (default) |
| End of any line | $ with m |
$ with re.MULTILINE |
$ with m |
Note the collision: \Z means "absolute end" in Python and "end, allowing a final newline" in PCRE and Perl. Copying an anchor between the two languages inverts its meaning. In PHP the two working forms are /^\d+\z/ and /^\d+$/D; both reject "123\n", which I verified rather than assumed.
Not Every Engine Is PCRE
The patterns in this article assume a backtracking engine. A significant part of the ecosystem does not use one, and the difference is not a detail — it changes which features exist at all.
| PCRE (PHP) | Python re |
JavaScript | Go / Rust (RE2) | .NET | |
|---|---|---|---|---|---|
| Lookahead | yes | yes | yes | no | yes |
| Lookbehind | fixed-width | fixed-width | yes (ES2018) | no | variable-width |
Backreferences \1 |
yes | yes | yes | no | yes |
| Atomic groups / possessive | yes | 3.11+ | no | n/a | yes |
| Worst-case time | exponential | exponential | exponential | linear | exponential |
Go's regexp package and Rust's regex crate implement RE2, which compiles to an automaton and guarantees linear time in the input length. That guarantee is exactly why they cannot offer lookarounds or backreferences — those features are what make backtracking necessary in the first place. It is a deliberate trade, and it is the right one for anything that runs untrusted patterns or untrusted input, which is the whole argument in catastrophic backtracking and ReDoS.
The practical consequence: a pattern that works in your PHP test suite may be rejected outright by a Go service, and the error will be a compile failure rather than a wrong result. That is the good outcome. The bad one is .NET's variable-length lookbehind — unique among these engines — which produces patterns that silently have no equivalent anywhere else.
Code Examples
PHP
<?php
declare(strict_types=1);
// Email validation
$email = 'user@example.com';
if (preg_match('/^[\w.+-]+@[\w-]+\.[a-zA-Z]{2,}$/', $email)) {
echo 'Valid email';
}
// Extract all URLs from text
$text = 'Visit https://example.com and https://richdevtools.com for tools.';
preg_match_all('/https?:\/\/[\w-]+(\.[\w-]+)+([\w.,@?^=%&:\/~+#-]*[\w@?^=%&\/~+#-])?/', $text, $matches);
print_r($matches[0]);
// Named groups for date parsing
$date = '2026-02-22';
if (preg_match('/^(?P<year>\d{4})-(?P<month>\d{2})-(?P<day>\d{2})$/', $date, $m)) {
echo "Year: {$m['year']}, Month: {$m['month']}, Day: {$m['day']}";
}
// Replace with callback
$result = preg_replace_callback('/\b(\w)(\w*)\b/', function (array $m): string {
return strtoupper($m[1]) . $m[2];
}, 'hello world');
// Result: "Hello World"
Python
import re
# Email validation
email = 'user@example.com'
pattern = re.compile(r'^[\w.+-]+@[\w-]+\.[a-zA-Z]{2,}$')
if pattern.match(email):
print('Valid email')
# Extract named groups
date = '2026-02-22'
m = re.match(r'^(?P<year>\d{4})-(?P<month>\d{2})-(?P<day>\d{2})$', date)
if m:
print(f"Year: {m.group('year')}, Month: {m.group('month')}")
# Find all matches with findall
text = 'IP addresses: 192.168.0.1 and 10.0.0.255'
ips = re.findall(r'\b(?:\d{1,3}\.){3}\d{1,3}\b', text)
print(ips) # ['192.168.0.1', '10.0.0.255']
# Substitution
result = re.sub(r'\bfoo\b', 'bar', 'foo foobar foo', flags=re.IGNORECASE)
print(result) # 'bar foobar bar'
JavaScript
// Email validation
const email = 'user@example.com';
const emailRegex = /^[\w.+-]+@[\w-]+\.[a-zA-Z]{2,}$/;
console.log(emailRegex.test(email)); // true
// Extract all matches (global flag)
const text = 'Prices: $100 and $250 and $1999';
const prices = text.match(/\$\d+/g);
console.log(prices); // ['$100', '$250', '$1999']
// Named groups (ES2018+)
const date = '2026-02-22';
const { groups } = date.match(/^(?<year>\d{4})-(?<month>\d{2})-(?<day>\d{2})$/) ?? {};
console.log(groups); // { year: '2026', month: '02', day: '22' }
// Replace with function
const result = 'hello world'.replace(/\b(\w)/g, c => c.toUpperCase());
console.log(result); // 'Hello World'
Three API Traps That Are Not Regex Bugs
The pattern is fine; the function around it is not. These three cost me more debugging hours than every quantifier mistake combined.
preg_match has three return values, and two of them are falsy. It returns 1 for a match, 0 for no match, and false when the pattern itself failed — a compile error, a backtrack-limit blow-up, or invalid UTF-8 under /u, which I demonstrated above. Written the usual way, a broken pattern is indistinguishable from clean input:
// WRONG - treats "the regex engine failed" as "input is invalid"
if (!preg_match($pattern, $input)) {
throw new ValidationException('Invalid format');
}
// RIGHT - a failure is a bug, not a validation result
$result = preg_match($pattern, $input);
if ($result === false) {
throw new RuntimeException('Regex failed: ' . preg_last_error_msg());
}
if ($result === 0) {
throw new ValidationException('Invalid format');
}
The reverse case is worse. A security check written as if (preg_match($blocklist, $input)) { reject(); } fails open the moment the engine errors: false is falsy, nothing is rejected, and the malformed input sails through. Always compare against 1 or 0 explicitly on any pattern that guards something.
A JavaScript regex with /g is stateful. RegExp.prototype.test advances lastIndex on a match and resets it only when the match fails, so a reused global regex alternates between true and false on identical input:
const g = /a/g;
g.test('a'); // true
g.test('a'); // false - lastIndex is now 1
Both results came from Node v26.5.0, and this is not a bug — it is what /g is for. Drop the flag for .test(), or build the regex literal fresh inside the function rather than hoisting it to module scope.
re.match in Python is not re.search. re.match anchors at the start of the string but not the end, so re.match(r'\d+', '123abc') matches happily. The three are distinct: match anchors the start, search scans anywhere, and fullmatch requires the whole string — which is the one you almost always want for validation, and the one that sidesteps the trailing-newline problem entirely because it has no $ to misinterpret.
Where to Go from Here
Regular expressions reward the time you invest in understanding them. The fundamentals - character classes, quantifiers, groups, and anchors - cover 90% of everyday use cases. Lookaheads and lookbehinds handle the remaining complex scenarios without consuming characters.
The 20 patterns above are starting points. Real-world input is messier than any example - always test with edge cases: leading and trailing whitespace, Unicode characters, very short or very long strings, and inputs designed to exploit greedy backtracking.
Three of those edge cases are worth turning into standing habits, because they are the ones that produce a wrong answer rather than an obvious failure. Write \p{L} when you mean letters and [0-9] when you mean ASCII digits, so the pattern says which one it meant. Anchor validation with \z (or fullmatch) rather than $, so a trailing newline cannot pass. And check the return value of preg_match against 1 explicitly, so an engine failure can never be mistaken for clean input.
Use the Regex Tester to experiment with patterns interactively as you build and debug your expressions.