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, and awk. 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 contains or split will 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 — \d means 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.

More Articles

The UUID Regex Everyone Copies Rejects UUID v7

The most-copied UUID regex caps versions at 5, so it rejects the v7 and v8 that RFC 9562 standardised in 2024. I tested it against eleven inputs.

26 August, 2026

llms.txt After Eighteen Months: What the Request Logs Show

Adoption reached 10% of domains, but AI crawlers fetched llms.txt 408 times across 500M visits. The evidence, the audience that does read it, and what to ship.

25 August, 2026

Your Link Preview Is Broken Because the Crawler Never Saw the Page

Blank or wrong link previews are usually a crawler problem, not a tag problem: bot protection, client-side rendering, WebP images - and how to reproduce it.

23 August, 2026