ProgrammingIntermediate

Regular Expressions Explained: A Practical Guide for Developers Who Avoid Them

Regex doesn't need to be memorized wholesale — a practical reference covering the patterns you'll actually use, with real examples instead of abstract syntax diagrams.

DevFieldGuideJune 10, 2026 (updated June 26, 2026)6 min read
Share:

Regular expressions have a steep-looking syntax that scares people away from a tool that, for a handful of common patterns, is genuinely the fastest way to solve a real problem.

The patterns that cover 90% of real use

. any character except newline \d a digit (0-9) \w a word character (letters, digits, underscore) \s whitespace + one or more of the previous token * zero or more of the previous token ? zero or one of the previous token (optional) ^ start of string/line $ end of string/line [abc] any one of a, b, or c [^abc] any character except a, b, or c

That's genuinely most of what you'll reach for day to day — everything else is a variation or combination of these.

Matching in JavaScript

js
const hasDigit = /\d/.test("abc123");        // true
const digits = "abc123def456".match(/\d+/g); // ["123", "456"]
const cleaned = "  hello  world  ".replace(/\s+/g, " ").trim(); // "hello world"

.test() checks for a match and returns a boolean. .match() (with the g flag) returns every match. .replace() swaps matched text — pairing it with \s+ (one or more whitespace characters) is the standard way to collapse repeated spaces into one.

Capture groups: extracting specific parts

Parentheses create a capture group — a piece of the match you can pull out separately, not just confirm exists:

js
const match = "2026-07-31".match(/(\d{4})-(\d{2})-(\d{2})/);
// match[0] = "2026-07-31" (the full match)
// match[1] = "2026", match[2] = "07", match[3] = "31"

Named groups make this more readable for anything with more than two or three captures:

js
const match = "2026-07-31".match(/(?<year>\d{4})-(?<month>\d{2})-(?<day>\d{2})/);
match.groups.year;  // "2026"

Common real-world patterns

js
// Validate a simple slug (lowercase letters, numbers, hyphens)
/^[a-z0-9]+(-[a-z0-9]+)*$/.test("my-article-slug");  // true
 
// Extract a hashtag
"Check out #devfieldguide today".match(/#\w+/);  // ["#devfieldguide"]
 
// Split on multiple possible delimiters
"a, b;c  d".split(/[,;\s]+/);  // ["a", "b", "c", "d"]
 
// Strip HTML tags (naively — not safe for untrusted input, see below)
"<p>Hello</p>".replace(/<[^>]+>/g, "");  // "Hello"

Greedy vs. lazy matching

By default, + and * are greedy — they match as much as possible before backtracking. Adding ? after them makes them lazy — matching as little as possible instead:

js
"<a><b>".match(/<.+>/);   // ["<a><b>"] — greedy, matches the whole thing
"<a><b>".match(/<.+?>/);  // ["<a>"] — lazy, stops at the first ">"

This distinction is the single most common source of "why did my regex match way more than I expected" confusion — when a match is unexpectedly long, check whether a quantifier needs to be lazy instead of greedy.

Catastrophic backtracking: the real performance risk

Certain regex shapes — particularly nested quantifiers like (a+)+ — can cause the engine to try an exponential number of backtracking combinations on specific input, taking seconds or minutes instead of microseconds:

js
// Vulnerable to catastrophic backtracking on a long non-matching string
/^(a+)+$/.test("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaab");

This is a real denial-of-service vector (ReDoS) if a regex like this ever runs against untrusted user input. The fix is almost always restructuring the pattern to remove the nested/ambiguous quantifier, not just hoping the input stays short.

Lookahead and lookbehind: matching without consuming

Sometimes you need to assert that something follows or precedes a match, without including it in the match itself:

js
// Match "100" only if followed by "px" — but don't include "px" in the match
"width: 100px".match(/\d+(?=px)/); // ["100"]
 
// Match "px" only if preceded by digits — but don't include the digits
"width: 100px".match(/(?<=\d)px/); // ["px"]

(?=...) is a lookahead — it checks what comes next without consuming it. (?<=...) is a lookbehind — the same idea, checking what came before. Both are useful for splitting a match from context that needs to be present but shouldn't be part of the extracted result.

Global flag state: a common gotcha with .test()

A regex with the g flag is stateful when reused with .test() — it remembers where the last match ended, which can produce surprising alternating results:

js
const re = /\d/g;
re.test("a1b2"); // true
re.test("a1b2"); // true
re.test("a1b2"); // false — lastIndex has moved past both matches
re.test("a1b2"); // true — resets and starts over

This is a real, frequently-hit bug: reusing the same global regex object across multiple .test() calls on similar input can silently alternate between true and false in a way that has nothing to do with the input actually changing. The fix is either not reusing a global-flagged regex for repeated .test() calls, or resetting re.lastIndex = 0 before each one.

Common mistakes

Common mistakes
  • Using regex to parse genuinely structured formats like HTML or JSON. Regex can't correctly handle arbitrarily nested structures — use a real parser (JSON.parse, a DOM parser, an HTML sanitizer library) for anything with real nesting, and reserve regex for flat, predictable text patterns.
  • Forgetting the g flag when you need every match, not just the first. .match() without g returns only the first match plus capture-group details; with g, it returns all matches but drops capture-group detail — pick based on which you actually need.
  • Writing a regex with nested quantifiers like (a+)+ or (a*)* against input length you don't control. This is the classic catastrophic-backtracking shape — restructure to avoid the ambiguity rather than hoping input stays short.
  • Escaping too little or too much. Characters like ., *, (, ) are special in regex and need \\ to match literally — forgetting this is a common source of a pattern matching more (or less) than intended.

Practical checklist

Best practices
  1. Reach for a named capture group the moment a pattern has more than two or three groups — match.groups.year is far more maintainable than match[3].
  2. Test regex against both matching and non-matching examples, including edge cases (empty string, very long string) — a pattern that "looks right" can still have a subtle greedy/lazy bug.
  3. Never build a regex dynamically from unsanitized user input without escaping special characters first — an attacker-controlled pattern fragment can turn into a ReDoS vector or an unintended match.
  4. For anything genuinely complex (parsing a whole config file format, validating structured data), use a proper parser or schema validator instead of stretching regex past where it's the right tool.

Frequently Asked Questions

DevFieldGuide
DevFieldGuide

Editorial Team

Practical tutorials and developer tools, written and maintained by the DevFieldGuide team.

Enjoyed this article?

Get the next one straight to your inbox, along with the best of what we publish each week.

Related Articles

More in Programming

View all