The test string was somedata123. I wanted the digits.

So I wrote the thing that felt obvious:

(.*\d{3})

One match. That part was fine. The panel on the right told me the rest: group 1, positions 0 to 11, somedata123. Not 123. All of it. A pattern that ends in “exactly three digits” had handed me back every character I gave it, including the eight letters I never asked for.

Above the test string was a small counter. Steps: 8. Time: 0 µs.

Eight steps, to match eleven characters against a pattern with two parts in it. That number is the engine’s own bookkeeping, and it turned out to be the most useful thing on the screen. Eight steps means the engine did not walk straight to the answer. It walked past it, discovered it had gone too far, and came back.

Why does a pattern that asks for three digits hand back the whole line?

I have been writing regular expressions for a long time, and for most of that time my method was: write something plausible, test it, add a ? somewhere, test again, keep whichever version stopped complaining, and never ask why. That method works often enough to feel like skill. It fails the way all pattern matching by vibes fails: quietly, on the one input nobody thought about, in production.

This is the article I wish I had read instead. It goes slowly, and it goes all the way to the end, because the interesting part of regex is not the syntax. It is what the engine does between the characters.

Two different things share the name

A regular expression is a declaration. It says what a match looks like.

A regex engine is a procedure. It says how to go looking.

Almost every confusing regex moment comes from reading the declaration and expecting the procedure to be equally tidy. The declaration (.*\d{3}) reads like a sentence: anything, then three digits. The procedure is closer to a person reading a line of text with one finger, moving forward greedily, and occasionally sliding the finger back when the rest of the sentence refuses to fit.

Keep both in mind and the surprises mostly stop. For the rest of this article I will call the second one the cursor, because that is what it behaves like: a position that moves through your text, tries something, and is allowed to change its mind.

The pattern says what. The cursor decides where. Most bugs live in the gap.

A workbench, so you can argue with me

Reading about regex is the slowest way to learn it. Every claim below is something you can break in about four seconds, and breaking it is the point.

I built a small workbench for this article. It is not regex101, the tool I have been using since roughly the time this page was first written, and it is not trying to be. regex101 has more engines, a step debugger, a pattern library, and a much deeper feature set. Mine does four things I wanted within arm’s reach of the prose: show every match with its positions, describe each token in plain language, point at the traps, and hand over the JavaScript that does the same job.

The layout borrows from the tool that taught me: a field for the regular expression, a field for the test string, and panels for match information and an explanation. Both tools run the pattern with the browser’s own JavaScript engine, so what you see is what your code will do.

Start with the example called “The greedy trap”. That is the pattern from the opening, waiting for you.

Matching one character at a time

At the bottom, a regex is a list of things that each consume one character, plus instructions about how many times and in what order.

You writeIt matchesWorth knowing
athe letter aMost characters mean themselves
.any character except a line breakWith the s flag, line breaks too
\da digit 0 to 9Always ASCII, even with the u flag
\wa letter, digit, or underscoreASCII only, so é is not a word character
\sa space, tab, or line breakGenerous: includes most Unicode spaces
\D \W \Sthe opposite of eachCapital letter means “not this”
[abc]one of a, b, or cA character class: one character, many options
[^abc]one character that is not a, b, or cThe ^ inside the brackets means “not”
[a-z]one character in that rangeA range of code points, not of letters
\.a literal dotA backslash switches off a special meaning

Two of those rows cause most of the trouble.

The first is that a character class matches exactly one character. [abc] is not the word “abc” and it is not “a or b or c” as phrases. It is one character, chosen from a set. Inside those brackets, most punctuation loses its powers: [.|*+] is four ordinary characters, not a dot-any, an alternation, and two quantifiers. The original version of this article had a pattern with [A-Z|a-z] in it, which quietly also accepts a literal pipe. It works anyway, which is exactly why nobody noticed for years. [A-Za-z] is what was meant. Paste [A-Z|a-z] into the workbench and open the Pitfalls tab; it will tell you about the pipe.

The second is that a range is a span of code points. [A-z] looks like “all the letters” and is genuinely popular. It covers A to Z, then keeps going through [, \, ], ^, _, and a backtick, before reaching a to z. Six characters you did not intend, sitting in the middle of your validator.

How much is “some”?

A quantifier follows something and says how many of it.

QuantifierMeaning
*zero or more
+one or more
?zero or one, which is to say optional
{3}exactly three
{2,}two or more
{2,4}between two and four

That table is the easy half. The half that decides your bugs is the appetite, and every quantifier has one.

By default quantifiers are greedy. They take as much as they can, and only give characters back when the rest of the pattern cannot fit. Add a ? after the quantifier and it becomes lazy: it takes as little as it can, and only takes more when the rest of the pattern demands it.

The clearest demonstration I know is pulling quoted strings out of a line. The test string is say "hello" and "goodbye" now, and the only difference between the two patterns is one question mark:

ONE QUESTION MARK, TWO ANSWERS
Greedy: ".*"
1 match
The dot-star runs to the end of the line, then reverses until it finds a final quote. It matches "hello" and "goodbye" as one long string, swallowing the and in the middle.
Lazy: ".*?"
2 matches
The dot-star takes nothing, then accepts one character at a time only while the closing quote refuses to appear. It stops at the first one, so each quoted string is its own match.

Both patterns are correct regexes. Only one of them answers the question a human was asking. A third version, "[^"]*", gets the same two matches without any reversing at all, by describing where the match has to stop instead of describing everything and hoping. That idea comes back when we get to log lines.

Which makes greed look like the obvious culprit in the opening puzzle, and it is worth saying plainly that it is not. Both .*(\d{3}) and .*?(\d{3}) capture 123 from somedata123. The appetite changes the route the cursor takes; it does not explain why my group came back eleven characters long. Something else is going on, and we need groups before we can name it.

There is one more thing about greed that has nothing to do with correctness. .* is also why some patterns are fast and some patterns hang a server, and I will come back to that with a stopwatch.

Positions are not characters

Some tokens match nowhere. They consume no text at all and only assert something about the place the cursor is standing.

TokenAsserts
^the start of the text, or of a line with the m flag
$the end of the text, or of a line with the m flag
\ba word boundary
\Bnot a word boundary
(?=...)what follows matches here
(?!...)what follows does not match here
(?<=...)what precedes matches here
(?<!...)what precedes does not match here

\b is the one worth internalising, because it is the difference between searching for a word and searching for some letters that happen to be in one. A word boundary is the seam between a \w character and anything that is not one, including the very start and end of the text.

\bcat\b

On the line cat, category, concat, a cat sat, CAT scan, the cat. that finds three matches. It skips category and concat, because neither has a boundary on both sides of those three letters, and it skips CAT, because no flag told it to ignore case. Delete the two \b tokens in the workbench and the count jumps as category and concat join in.

Lookaround is the same idea with a whole pattern inside it. Because it asserts without consuming, the thing you looked at stays outside the match:

\d+(?= ms)

Against p50 18 ms, p95 240 ms, payload 512 kB, timeout 3000 ms this matches 18, 240, and 3000. The ms is required but never captured, and 512 is left alone because kB is not ms. That is the whole trick of lookaround: describe the context, keep the payload.

Groups are where regex becomes useful

Up to here, regex has only answered yes or no. Groups are what turn it into a way of getting data out.

A plain (...) does two jobs at once, and conflating them is the source of the problem in the opening.

  1. It groups tokens, so a quantifier or an alternation applies to all of them.
  2. It captures whatever those tokens matched, and remembers the text.

When you only want the first job, use a non-capturing group, (?:...). It keeps your group numbers meaningful and tells the next reader that you did not intend to collect anything here.

\b\d{1,3}(?:\.\d{1,3}){3}\b

The (?:...) there exists purely so {3} can repeat “dot then up to three digits”. Nothing needs storing, so nothing is.

When you do want the text, prefer a name over a number:

(?<year>\d{4})-(?<month>0[1-9]|1[0-2])-(?<day>0[1-9]|[12]\d|3[01])

Now the result carries a groups object, and the reading code says what it means:

const match = "2026-09-11".match(pattern);
const { year, month, day } = match.groups;

Compare that with match[1], match[2], and match[3], which are correct right up until somebody inserts a group in the middle and every number after it shifts by one. Named groups still get numbers as well, so nothing is lost by naming them. Named groups arrived in ES2018 and are available in every current browser and runtime. They are the single cheapest readability upgrade available in regex.

That date pattern is also a small lesson in what a range costs. Writing 0[1-9]|1[0-2] instead of \d{2} is what makes it reject month 13. It happily accepts 2026-02-29, because nothing in a regex knows how long February is. A pattern can enforce shape. It cannot enforce meaning.

The last thing groups give you is a way to refer back to what was captured, inside the same pattern:

\b(\w+)\s+\1\b

\1 does not mean “that pattern again”. It means “the same characters group 1 just matched”. On I think that that sentence has has a problem, but this one one does not. it finds that that, has has, and one one. A repeated-word detector in nine characters, and one of the few regexes I have written that found real mistakes in my own writing.

So why did three digits hand back the whole line?

Now the opening question has all the pieces it needs.

(.*\d{3})

Here is what the cursor actually did to somedata123:

  1. It started at position 0 and handed control to .*.
  2. .* is greedy, so it consumed all eleven characters and stopped at the end.
  3. \d{3} now needed three digits, and there was nothing left. Fail.
  4. So .* gave one character back. \d{3} tried again at 3, found one digit and then the end. Fail.
  5. .* gave another back. \d{3} tried at 23. Two digits, then the end. Fail.
  6. .* gave a third back, leaving it holding somedata. \d{3} tried at 123 and succeeded.

The pattern matched. The engine’s counter said eight steps, and whatever its exact accounting, the shape of the work is visible: it overshot, then reversed three times. That reversing is called backtracking, and it is the mechanism behind nearly everything in this article that looks like magic.

And now the answer to the actual question, which was never really about greed.

The group captured everything because I put the parenthesis in the wrong place. (.*\d{3}) wraps both parts, so group 1 is defined as “the dot-star and the three digits together”. It is doing exactly what I wrote. Positions 0 to 11 is not the engine being clever or stupid; it is the span of the group I drew.

.*(\d{3})

Move the opening parenthesis three characters to the right and group 1 holds 123. The overall match still runs from 0 to 11, because .* is still there and still greedy, still consuming the letters on its way. It is just no longer inside the thing I asked to keep.

(\d{3})

Or drop the dot-star entirely, and the match itself starts at position 8. There was never a reason for it to begin at the start of the line. I put one there.

That is the lesson I actually took from eight steps. A capture group is a decision about scope, and scope is the thing I was not thinking about at all. I was thinking about which characters to match, and the engine was answering a question about which characters to keep.

Order matters, and alternation has one

| separates alternatives, and the engine tries them strictly left to right, keeping the first one that allows the rest of the pattern to succeed.

That “rest of the pattern” clause is important, and it is why alternation is not a set. \b(cat|category)\b and \b(category|cat)\b behave identically on the word category, because the word boundary at the end rejects the short option and forces a retry. Remove the trailing \b and the order suddenly decides the answer: the first pattern matches just cat and leaves egory behind.

Alternation also has a scope trap of its own. | has the lowest precedence of anything in a regex, so it splits the whole enclosing group.

^cat|dog$

That does not mean “the line is cat or dog”. It means “the line starts with cat, or the line ends with dog”. The fix is a group that shows where the choice belongs:

^(?:cat|dog)$

Load the Alternation without a group example in the workbench and watch cat match the first line while dog matches the second, even though neither line is only cat or dog.

Email: the pattern everybody writes and nobody should trust

This is the pattern from the original version of this article, and I am keeping it, because it is genuinely useful and because it is a good place to be honest.

\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b

Some letters, digits, or the usual local-part punctuation, then an @, then a host-looking thing, then a dot and at least two letters. Put it in the workbench and it will find every address in a wall of text, which is exactly the job I want from it.

Then try these:

john..doe@example.com
user@example..com
user@-example.com
"quoted local"@example.com

The first three all match, and none of them is a deliverable address. The fourth does not match, and it is legal. Two consecutive dots, a hostname starting with a hyphen, and a quoted local part are all things the pattern has an opinion about, and its opinion is wrong in both directions.

WHAT THIS PATTERN IS FOR

Finding addresses is not validating them

A regex can tell you where the candidates are. It cannot tell you that mail will arrive.
Good at
Finding, redacting, and extracting addresses from text
Bad at
Deciding whether an address is valid
Instead use
The platform's own parser, then a confirmation email

This is not a flaw in my pattern that a better pattern would fix. Email addresses are defined by a grammar with comments, quoting, and folding whitespace in it, and the only regex that accepts exactly that grammar is famously thousands of characters long and still does not prove anyone reads the mailbox. So in an application I let the platform parse it, with something like PHP’s filter_var() or an equivalent library, and then verify ownership by sending a message. Regex finds candidates. Delivery proves existence.

The same reasoning applies to the URL pattern the original article used, \b(https?:\/\/)?([\da-z\.-]+)\.([a-z\.]{2,6})([\/\w \.-]*)*\/?\b. It is fine for spotting links in prose and quietly wrong about real URLs, which can contain ports, credentials, internationalised hosts, query strings, and fragments. Open the URLs in prose example to see both sides at once. In code I use the URL constructor and let a real parser be a real parser. That pattern also contains ([\/\w \.-]*)*, a star applied to a group that already ends in a star, which we are about to see is a genuinely dangerous shape. The Pitfalls tab flags exactly this construction. It is the reason I built that tab.

An address that counts to 255

Sometimes the honest answer is that regex can do the job, as long as you accept how it has to be written.

Four groups of digits separated by dots is easy:

\b\d{1,3}(?:\.\d{1,3}){3}\b

And it cheerfully matches 999.999.999.999, which is not an address. The pattern answers “four numbers with dots between them”, which was never the question.

Regex has no concept of “less than 256”. It only has characters. So a numeric range has to be spelled out as the set of digit shapes that fall inside it:

\b(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(?:\.(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}\b

Each octet is four alternatives: 250 to 255, then 200 to 249, then 100 to 199, then 0 to 99. It is not beautiful, but it is correct in a way the short one is not. It accepts 10.0.0.14, 0.0.0.0, and 192.168.1.255, and it rejects 999.999.999.999, 256.1.1.1, and 192.168.01.1.

That last rejection is my favourite, because it was not on purpose the first time I wrote a pattern like this. There is no alternative that matches a leading zero followed by another digit, so 01 fails, which is arguably right: 192.168.01.1 is not a normal address, and a leading zero has historically been read as octal by some parsers. The pattern is stricter than I intended, and the strictness happens to be correct. That is worth knowing about your own patterns, in both directions.

A log line becomes an object

Here is where named groups stop being a style preference and start being the whole point. This is an access log line, the kind I read far more often than I would like:

10.0.0.14 - - [11/Sep/2026:14:32:43 +0200] "GET /howtos/harness-regex101/ HTTP/1.1" 200 48213

And a pattern that turns it into fields:

^(?<ip>\S+) \S+ \S+ \[(?<time>[^\]]+)\] "(?<method>[A-Z]+) (?<path>[^"]*?) HTTP/(?<version>\d\.\d)" (?<status>\d{3}) (?<bytes>\d+|-)

Which reads, field by field: an address, two fields I do not care about, a bracketed timestamp, then a quoted request split into method, path, and protocol version, then a status code and a byte count that might be a literal dash.

Two details in there are deliberate and worth stealing.

[^\]]+ for the timestamp instead of .+. A negated class cannot run past the closing bracket, because the bracket is not in its set. .+ can, and then has to backtrack its way home. Describing the boundary is both faster and clearer than describing everything and hoping.

(?<bytes>\d+|-) because real logs contain a dash where a number should be. Any pattern that meets real data eventually grows an alternative like this one. That is not the pattern getting uglier; that is the pattern getting accurate.

And then the code is almost boring, which is the goal:

const line =
  /^(?<ip>\S+) \S+ \S+ \[(?<time>[^\]]+)\] "(?<method>[A-Z]+) (?<path>[^"]*?) HTTP\/(?<version>\d\.\d)" (?<status>\d{3}) (?<bytes>\d+|-)/gm;

for (const match of log.matchAll(line)) {
  const { ip, path, status, bytes } = match.groups;

  if (Number(status) >= 400) {
    console.log(status, path, ip, bytes === "-" ? 0 : Number(bytes));
  }
}

Regex in JavaScript, where the surprises live

The pattern is only half of it. JavaScript’s regex API has a handful of behaviours that catch people who understand regex perfectly well.

The g flag gives your pattern a memory

This is the one I still occasionally walk into. A regex with g or y keeps a lastIndex property, and test() and exec() both move it.

const pattern = /cat/g;

pattern.test("cat"); // true
pattern.test("cat"); // false

The same question, the same string, two different answers. After the first call, lastIndex is 3, so the second search starts past the end, fails, and resets to 0. A third call would return true again.

Without g, test() is stateless and returns true every time. So a shared, module-level global regex used for validation is a bug waiting for its second caller. Either drop the g flag when you only need a yes or no, or create the regex where you use it.

matchAll and replaceAll insist on g

Both throw a TypeError rather than guessing:

TypeError: String.prototype.matchAll called with a non-global RegExp argument

I have come to appreciate this. Compare it with replace(), which silently replaces only the first match when you forget the flag. That silence has cost me more time than the exception ever will. You can watch it happen in the workbench: open the Rewrite tab on the redaction example and remove the g, and the second address stays in the text.

match changes shape depending on a flag

String.prototype.match returns two completely different things:

  • Without g: the first match as an array, with index, groups, and the captures in it.
  • With g: a flat array of matched strings, with no positions and no capture groups at all.

That is a genuine API wart. This is why matchAll exists, and it is what I reach for now whenever I want more than a single match:

const matches = [...input.matchAll(/(?<key>\w+)=(?<value>\S+)/g)];

The spread matters, because matchAll returns an iterator rather than an array.

Replacement strings have their own tiny language

Inside a replacement string, $ is special:

TokenInserts
$&the whole match
$1capture group 1
$<name>a named group
$`the text before the match
$'the text after the match
$$a literal dollar sign

Which makes reformatting almost declarative:

"11/09/2026".replace(
  /(?<day>\d{2})\/(?<month>\d{2})\/(?<year>\d{4})/,
  "$<year>-$<month>-$<day>",
); // "2026-09-11"

When a string is not enough, pass a function. It receives the match, then each capture, then the offset, then the whole input, and the named groups last:

const output = text.replace(/\b\d+\b/g, (digits) =>
  Number(digits).toLocaleString("en-US"),
);

Two flags worth knowing about

d records positions. With it, every match gains an indices property, including one per named group:

const match = /(?<year>\d{4})/d.exec("x 2026");
match.indices.groups.year; // [2, 6]

v upgrades character classes. It does everything u does, and adds set operations inside the brackets, so you can subtract and intersect instead of hand-writing exclusions. It has been available across browsers since September 2023, and cannot be combined with u.

/[\p{Script=Greek}&&\p{Letter}]/v; // Greek letters only
/[\p{Letter}--[a-z]]/v;            // letters, minus the ASCII lowercase ones

That also fixes the \w and \d limitation from the very first table. \d is always [0-9], even with u, and \w does not include accented letters. If you need real Unicode, ask for it by property: \p{Nd} for decimal digits in any script, \p{L} for letters.

Never build a pattern from user input by hand

If a search box feeds a new RegExp(), then a user typing . has just asked to match every character, and a user typing (a+)+$ has asked for something worse. RegExp.escape() exists for this, and has been available across browsers since May 2025:

const pattern = new RegExp(RegExp.escape(userInput), "gi");

Its output looks alarming, because it escapes aggressively and with \x sequences: RegExp.escape("a.b") returns \x61\.b. That is deliberate, so the result stays safe no matter what it gets concatenated next to. Do not hand-roll a version of this with replaceAll; the edge cases are not worth the afternoon.

When the engine turns on you

Remember that the cursor backtracks. Now consider a pattern where there is more than one way to divide the same text:

^(a+)+$

Read as a declaration it is harmless, and it says “one or more groups of one or more a’s”. Read as a procedure it is a trap, because (a+)+ can split a run of a’s in an enormous number of ways, and if the match ultimately fails, the engine will try all of them before admitting it.

I measured this in Node 25.9 on my Mac, testing that pattern against some number of a characters followed by a ! so the match can never succeed:

Characters before the !Time to fail
2218 ms
2475 ms
26305 ms
281199 ms

Two more characters, roughly four times the work. The first sample in any run is distorted by warm-up, so these are from later in the loop. The exact milliseconds do not matter; the doubling does. At 28 characters the pattern needed more than a second. A handful more and it is minutes. The equivalent safe pattern, ^a+$, ran a thousand times in a fraction of a millisecond on the same input.

That gap is not academic. This is the shape of a denial-of-service bug: one short string in a form field, one blocked event loop, one server that stops answering anybody.

The dangerous shape is easy to recognise once you know it:

  • A quantifier applied to a group that already contains an unbounded quantifier: (a+)+, (\w*)*, ([\/\w \.-]*)*.
  • Two adjacent unbounded quantifiers that can match the same characters: .*.*, \s*\s*.
  • Alternatives that overlap, repeated: (a|ab)+.

The fixes are usually smaller than the bug:

  • Be specific instead of general. [^"]* instead of .* inside quotes.
  • Bound the repetition. {1,64} instead of + when you know the field has a sane maximum.
  • Anchor the pattern, so a failed match does not get retried at every position in the string.
  • Remove the ambiguity. (a+)+ was only ever a+.

The Pitfalls tab in the workbench looks for these shapes. Load A pattern that can hang, then append a ! to the test string and feel the difference. It is a linter, not a proof: a pattern with no warnings can still be wrong, and it can still be slow on input I did not think of.

How I build a pattern now

The method changed more than my knowledge did. This is what I do instead of adding question marks until the tests pass.

THE BORING METHOD THAT WORKS
01
Collect real input
Three lines that must match, and three that must not. The near-misses teach more than the matches.
02
Describe the boundaries
Anchors and negated classes first. Decide where the match stops before deciding what it contains.
03
Build up in pieces
One field at a time, checking the match count after each addition rather than at the end.
04
Name the captures
If a group is worth keeping, it is worth a name. If it is not, make it (?:...).
05
Read it back
Have something explain the pattern token by token. If a token surprises you, that is the bug.
06
Try to break it
Empty input, a very long line, a nested quantifier check, and the one weird row from production.

The step I skipped for years is the fifth one. Writing a regex is easy and reading one is hard, so I wrote and never read. Having the pattern described back to me, one token at a time, is how I find the difference between what I meant and what I typed. It is the whole reason the Explanation tab exists, and the reason I built the workbench instead of just writing about the tool I already had.

One more habit, which is about people rather than patterns. A shared regex link carries its test string with it. Before I paste anything into a tool and copy the URL into a chat or a ticket, I replace the tokens, passwords, internal hostnames, and customer data with something invented. The pattern is the part worth sharing. The sample almost never is.

Back to eight steps

The pattern in the opening was not broken. It was answering a question I had not realised I was asking.

So, why does a pattern that asks for three digits hand back the whole line? Because (.*\d{3}) never asked for three digits. It asked to keep everything from the start of the line up to and including three digits, and the greedy dot in front made sure “the start of the line” was where the match began. Eight steps was the engine overshooting the end, reversing three times, finding 123, and then faithfully handing me the span I had drawn a parenthesis around.

The digits were never the hard part. The scope was.

That is the shift worth having, and it is small enough to keep. A regex is not a description of the text you want. It is a set of instructions to something that walks, consumes, asserts, remembers, and reverses. Once I started reading my own patterns as instructions, the surprises became predictions, and the counter in the corner became the most honest feature in the tool.

It is still the first thing I look at.

The cursor goes as far as it can, then comes back three steps, ashamed, and hands you everything you thought to put in brackets.


Buy Me a Coffee