You've seen the syntax before — something like ^[\w.-]+@[\w.-]+\.[a-zA-Z]{2,}$ — and either it looked like line noise or you recognized it as an email validator. Regular expressions are one of those tools that feel impenetrable at first and then become indispensable. Once you understand the building blocks, that dense string of symbols starts to read like a sentence.
A regular expression (regex) is a pattern that describes a set of strings. Give it to a regex engine and it will tell you whether a string matches, where the match starts, or what specific parts of the string match different pieces of the pattern. The same pattern works in Python, JavaScript, Go, Rust, grep, sed, your code editor's find-and-replace — regex is one of the most portable skills in programming.
How a regex engine works
The engine reads your pattern left to right and tries to match it against the input string. At each position in the string, it attempts to consume characters according to the pattern. If it gets stuck, it backtracks and tries a different path. This process is why regex can match complex nested patterns but also why pathological patterns can be slow on certain inputs.
Most practical regex operations fall into three categories: test (does this string match the pattern?), extract (give me the parts of the string that match), and replace (substitute matched text with something else).
The core syntax
Literal characters match themselves. The pattern cat matches the string "cat" anywhere it appears. Case matters: cat does not match "Cat" unless you use a case-insensitive flag.
The dot . matches any single character except a newline. c.t matches "cat", "cut", "c3t", and "c t".
Character classes [...] match any one character from the set. [aeiou] matches any vowel. [a-z] matches any lowercase letter. [0-9] matches any digit. A caret at the start negates the class: [^aeiou] matches any character that is not a vowel.
Shorthand classes cover common cases. \d means [0-9] (digit). \w means [a-zA-Z0-9_] (word character). \s means any whitespace (space, tab, newline). Uppercase versions are negations: \D matches anything that is not a digit.
Anchors assert position without consuming characters. ^ matches the start of the string (or start of a line in multiline mode). $ matches the end. \b matches a word boundary — the position between a word character and a non-word character.
Quantifiers control repetition. ? means zero or one. * means zero or more. + means one or more. {3} means exactly three. {2,5} means between two and five. By default quantifiers are greedy — they match as much as possible. Add ? after a quantifier to make it lazy: .*? matches as little as possible.
Groups (...) treat multiple characters as a unit. (ab)+ matches "ab", "abab", "ababab". Groups also capture their match, which you can reference later.
Alternation | means "or". cat|dog matches either "cat" or "dog". Use a group to scope it: gr(a|e)y matches "gray" or "grey".
**Escaping \** treats a metacharacter as a literal. To match an actual dot, write \.. To match a parenthesis, write \(.
Practical examples
Validate an email address (simplified):
^[\w.+-]+@[\w-]+\.[a-zA-Z]{2,}$
Reads as: start of string, one or more word characters or .+-, an @, one or more word characters or -, a literal dot, two or more letters, end of string.
Extract a date in YYYY-MM-DD format:
\d{4}-\d{2}-\d{2}
Four digits, a dash, two digits, a dash, two digits.
Match a hex color code:
#[0-9a-fA-F]{6}\b
A literal #, exactly six hex digits, followed by a word boundary.
Find lines that start with a comment in Python:
^\s*#
Optional whitespace at the start, then a #.
Remove leading and trailing whitespace (replacement pattern):
^\s+|\s+$
Match whitespace at the start or whitespace at the end, replace with an empty string.
Match a URL:
https?://[\w./%-]+
http followed by an optional s, ://, then word characters, dots, slashes, percent signs, or hyphens.
Capture groups and references
When you wrap part of a pattern in parentheses, the engine remembers what that group matched. You can reference it in a replacement string or later in the same pattern.
In most languages, $1 or \1 refers to the first capture group. If you're reformatting dates from YYYY-MM-DD to DD/MM/YYYY:
Pattern: (\d{4})-(\d{2})-(\d{2})
Replacement: $3/$2/$1
Named groups make this cleaner. In Python: (?P<year>\d{4})-(?P<month>\d{2})-(?P<day>\d{2}). In JavaScript: (?<year>\d{4})-(?<month>\d{2})-(?<day>\d{2}). Then you reference them by name instead of number.
Non-capturing groups (?:...) group characters without capturing. Use this when you need grouping for alternation or quantifiers but don't need the captured value.
Lookahead and lookbehind
These are zero-width assertions — they check what comes before or after a position without consuming characters.
Positive lookahead (?=...) — match only if followed by the pattern. \d+(?= dollars) matches a number only if "dollars" follows it, without including "dollars" in the match.
Negative lookahead (?!...) — match only if not followed by the pattern. \bcat(?!fish)\b matches "cat" but not "catfish".
Positive lookbehind (?<=...) — match only if preceded by the pattern. (?<=\$)\d+ matches a number preceded by a dollar sign.
Negative lookbehind (?<!...) — match only if not preceded by the pattern.
Lookarounds are extremely useful when you need to match something based on context without including that context in the result.
Flags
Flags modify how the engine behaves. Common ones:
i(case-insensitive) —catmatches "Cat", "CAT", "cAt"g(global) — find all matches, not just the first (JavaScript)m(multiline) —^and$match start/end of each line, not the whole strings(dotall) —.matches newlines toox(verbose) — ignore whitespace and allow comments in the pattern, for readability
Common mistakes
Forgetting to escape dots. 3.14 as a pattern matches "3X14" because . means any character. Write 3\.14 to match a literal dot.
Greedy quantifiers consuming too much. If you're extracting the content of HTML tags with <.*>, a greedy .* will match from the first < to the last > on the line, consuming everything in between. Use <.*?> for lazy matching, or better yet <[^>]*>.
Anchoring when you don't mean to. If you write ^cat expecting to find "cat" anywhere in a multi-line string, you'll only match "cat" at the very start of the string (unless multiline mode is on).
Catastrophic backtracking. Patterns like (a+)+b can cause exponential backtracking on strings that almost match. If your regex is slow on certain inputs, look for nested quantifiers and restructure the pattern.
Regex in different languages
The syntax is largely consistent across languages, but there are differences in features and flags.
JavaScript — Use regex literals (/pattern/flags) or new RegExp('pattern', 'flags'). Methods: string.match(), string.replace(), string.search(), regex.test().
Python — Import the re module. re.search() finds a match anywhere, re.match() only at the start. re.findall() returns all matches. re.sub() replaces matches.
Go — Use the regexp package. Go uses RE2 syntax, which doesn't support lookaheads or backreferences (by design, for guaranteed linear-time matching).
grep / sed — Command-line tools use POSIX regex by default. Pass -E for extended regex (which supports +, ?, and | without escaping) or -P for Perl-compatible regex.
Regular expressions reward the time you invest in learning them. The syntax is dense, but the underlying ideas are simple: describe a pattern, and let the engine find it. Start with the basics — literals, character classes, quantifiers, anchors — and add lookaheads and capture groups as you need them. Most real-world regex problems only need a small fraction of the full syntax.