Skip to main content

Regex

How to Write a Regular Expression: A Step-by-Step Tutorial

Vibeus Moonscript

Updated 4 min read

How to Write a Regular Expression: A Step-by-Step Tutorial
On this page

Regular expressions have a reputation for being unreadable. /^(?=.*[A-Z])(?=.*\d).{8,}$/ doesn’t look like anything at first glance. The trick is that nobody writes a pattern like that in one go. You start with something that matches too much, test it, and tighten it one piece at a time.

This tutorial builds two real patterns that way: one that validates a date, and one that pulls fields out of log lines. Every step changes one thing, so you can see what each piece of syntax does. Paste the examples into the Regex Tester and watch the matches change as you go.

If you already know the basics and just need to look something up, the JavaScript regex cheat sheet is the quicker read.

Step 1: Start with a literal

The simplest regex is plain text. It matches exactly those characters, anywhere in the string:

/2026-09-15/.test('Due on 2026-09-15')  // true
/2026-09-15/.test('Due on 2026-09-16')  // false

That works for one date. To match any date, you need to describe the shape of the text instead of the exact characters.

Step 2: Replace characters with character classes

\d matches any single digit. Swap each digit for \d and the pattern now describes “four digits, dash, two digits, dash, two digits”:

/\d\d\d\d-\d\d-\d\d/

Other classes you’ll use constantly: \w (letter, digit, or underscore), \s (whitespace), and . (any character except a newline). Square brackets define your own set: [aeiou] is any vowel, [0-9a-f] is a hex digit, and [^,] is anything except a comma.

Step 3: Use quantifiers instead of repetition

Writing \d four times works, but quantifiers say it more clearly:

/\d{4}-\d{2}-\d{2}/

{4} means exactly four. The other quantifiers are + (one or more), * (zero or more), ? (optional), and {2,5} (between two and five).

Step 4: Anchor it

Test that pattern against Order 12026-09-155 and it still matches, because it finds 2026-09-15 inside the longer string. For validation you want the whole input to be a date, so anchor the pattern to the start (^) and end ($):

const isDate = /^\d{4}-\d{2}-\d{2}$/;

isDate.test('2026-09-15')        // true
isDate.test('Due 2026-09-15')    // false
isDate.test('2026-09-155')       // false

Forgetting anchors is the most common reason a validation regex “passes” input it shouldn’t.

Step 5: Tighten the ranges with alternation

\d{2} accepts 99 as a month. Alternation (|) inside a group lets you list the valid options:

/^\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\d|3[01])$/

Read the month part as “0 followed by 1–9, or 1 followed by 0–2”. The day part allows 01–09, 10–29, and 30–31.

Now 2026-13-01 fails. But 2026-02-30 still passes, and no reasonable regex will fix that: regular expressions check the shape of text, not calendar rules. Validate the format with the regex, then check the actual date with Date or a date library.

Step 6: Capture the parts you need

Parentheses do two jobs: they group alternatives, and they capture what matched so you can use it. Named groups make the captures readable:

const match = '2026-09-15'.match(/^(?<year>\d{4})-(?<month>\d{2})-(?<day>\d{2})$/);

match.groups.year   // '2026'
match.groups.month  // '09'

If you need a group only for alternation and don’t want to capture it, write (?:...).

Step 7: Extract many matches from real text

Now the second pattern. Given a log like this:

2026-09-15 10:42:07 ERROR Payment failed for order 8812
2026-09-15 10:42:09 INFO Retry scheduled
2026-09-15 10:43:30 WARN Card processor slow (2.4s)

Build the pattern the same way: date, space, time, space, level, space, the rest of the line. Keep only errors and warnings:

const pattern = /^(\d{4}-\d{2}-\d{2}) (\d{2}:\d{2}:\d{2}) (ERROR|WARN) (.+)$/gm;

const problems = [...log.matchAll(pattern)].map((m) => ({
  time: `${m[1]} ${m[2]}`,
  level: m[3],
  message: m[4],
}));
// [{ time: '2026-09-15 10:42:07', level: 'ERROR', message: 'Payment failed for order 8812' },
//  { time: '2026-09-15 10:43:30', level: 'WARN',  message: 'Card processor slow (2.4s)' }]

Two flags make this work. g (global) finds every match instead of stopping at the first, and matchAll requires it. m (multiline) makes ^ and $ match at the start and end of each line rather than the whole string.

Step 8: Add conditions with lookaheads

Some rules aren’t about order. “At least 8 characters, with an uppercase letter and a digit somewhere” is a set of conditions, and lookaheads express exactly that. (?=...) checks that something appears ahead without consuming any characters:

/^(?=.*[A-Z])(?=.*\d).{8,}$/

From the start of the string: somewhere ahead there’s an uppercase letter, somewhere ahead there’s a digit, and the whole thing is at least 8 characters. That’s the pattern from the top of this post, and now each part has a job.

Mistakes worth testing for

Unescaped dots. /example.com/ also matches exampleXcom, because . means any character. Escape it: /example\.com/.

Greedy matching. Quantifiers grab as much as they can:

'<b>one</b> and <b>two</b>'.match(/<b>.*<\/b>/)[0]   // '<b>one</b> and <b>two</b>'
'<b>one</b> and <b>two</b>'.match(/<b>.*?<\/b>/)[0]  // '<b>one</b>'

Adding ? after a quantifier makes it lazy, so it stops at the first closing tag.

Reusing a global regex with .test(). A regex with the g flag remembers where the last match ended (lastIndex), so calling .test() twice on the same string can return true and then false. Drop the g flag for yes/no checks.

Testing only the happy path. Before shipping a pattern, try an empty string, extra whitespace, a value that’s almost valid, and one that’s far too long. The fastest way is to paste them all into a tester and look at what’s highlighted.

Where to go next

Try it free

Regex Tester

Test and debug regular expressions with real-time matching and highlighting.

Open RegEx Tester

Written by

Vibeus Moonscript

Writes DevBottle's guides and builds the tools they cover. About DevBottle