Regex Tester with Match Positions, Capture Groups and Replace Preview

Type a pattern and some text and see every match with its position, its raw text and each capture group — null distinguished from empty — plus the result of a $& / $1 / $<name> replacement. Everything runs locally in your browser.

Flags, empty matches, the replacement preview

The hard part is seeing what matched

The slow part of writing a regex is not writing it, it is seeing it. exec hands you an array, but you still do not know where it matched, what exactly it swallowed, what each of your three parentheses caught, and whether the fourth one participated at all this time. Editor highlighting does not help much because it paints the whole match and the capture groups as one colour.

So what gets printed is the engine’s raw answer, one row per match: the position range, the matched text with control characters written literally so spaces, newlines and tabs are visible, and each capture group. In the group column 1: null and 1: are different statements — the first means that parenthesis did not participate in this match, the second means it participated and captured an empty string. When you are debugging, that distinction is the whole point.

How the flags are handled

Whatever letters you type in the flags box are passed straight to new RegExp. The accepted ones are d g i m s u y:

  • g decides whether everything is listed. With it, every match gets a row (up to 200); without it only the first match is reported and the message says so. If you want a count, use g.
  • i ignores case. m makes ^ and $ match at every line boundary rather than only the ends of the whole string — when $ will not match in multi-line text, m is missing nine times out of ten.
  • s lets . match line terminators. By default . excludes \n, \r, \u2028 and \u2029, so any “grab a block across lines” pattern needs s or [\s\S].
  • u turns on Unicode mode: quantifiers like {2,10} count code points rather than UTF-16 code units, so emoji and rare characters are not split in half, and invalid escapes in the pattern become errors instead of being silently tolerated.
  • y is sticky — it only tries at lastIndex; d adds indices to the result. Both are mostly useful for watching behaviour here.
  • An unknown or repeated letter is named explicitly instead of the engine’s blanket “syntax error”.

Match counts and empty matches

The match count is what the engine actually returned, empty matches included. a* against bbb produces 4 matches, at positions 0, 1, 2 and 3, each of them empty. That is not a bug, it is the definition: zero repetitions still match. Rows like these show “(empty match)” and a position marked “(empty)”.

The same applies to zero-width assertions — ^, $, \b consume nothing, so with g they fire repeatedly. If your match count looks absurd, check for a pattern that can match empty. The loop advances lastIndex by one so it terminates, but the number shown is the engine’s honest answer.

The replacement preview

The template box uses JavaScript’s own replacement syntax, because that is what String.replace will run in your real code:

  • $& is the whole match, $` is everything before it, $' is everything after — a notation that is easy to skim past in documentation.
  • $1…$99 fetch groups by number, $<name> by name. If the number is larger than the group count of the pattern, the token survives verbatim ($3 stays $3); if the group exists but did not participate in this match, it becomes an empty string. Those are different outcomes and the preview shows both.
  • $$ produces a literal $. So prefixing amounts with a dollar sign takes two characters.
  • Without g only the first occurrence is replaced, and the preview reflects exactly that.
  • Empty matches with g insert the template at every position: a* with the template - turns text into -b--b--b-. When the preview looks like that, the pattern is doing what you told it to.

When not to trust the result

  • It can hang. Patterns like (a+)+b or (x+x*)*y against text that does not match backtrack exponentially and the tab will freeze; there is no timeout guard. The 4000-character cap on the text limits the damage but does not prevent catastrophic backtracking. If you suspect one, test with two short lines first — never with a whole log file.
  • Unicode property escapes such as \p{Script=Han} require the u flag (or v on newer engines). They do not throw without it. Under legacy rules \p degenerates into the literal letter p, so \p{Script=Han} becomes “match the text p{Script=Han}” and returns zero matches — which reads like “my Chinese pattern is broken” when really a flag is missing. This silent degradation is the easiest thing here to misread.
  • Numeric group names are legacy: \1 inside a pattern is a backreference, and (?<1>...) still works on some engines. Nothing special happens here.
  • Positions are character indices in UTF-16 code units. With emoji or rare ideographs in the text, that is not the same as “the Nth character a human counts”.
  • No line numbers. The table gives offsets, not line:column. For long logs, split by line first.
  • This is JavaScript regex only. PCRE (PHP), Python and Go’s RE2 differ in syntax and features — RE2 in particular has no backreferences and no lookaround. A pattern that passes here must be re-checked in whatever language you ship it in.

Questions people ask

Why does the table stop at 200 rows? Beyond that you are almost certainly matching too much rather than wanting to read more. The count stat still reports the real number (even when truncated); only the listing stops. For the full set, split the text and run it in chunks.

Why does a group show null? Because that parenthesis did not participate in this particular match. With (a)|(b) against b, group 1 is undefined, printed as null. A group shown as empty did participate and captured zero characters — different situation, different row.

Does the text I paste get uploaded? No. The pattern is built by the browser’s own new RegExp call, matching and replacing happen in the browser, and your text is not set in front of anything else, so it does not leave this device.

Why does my Chinese pattern not match? Check for full-width punctuation first (: and : are different characters: . matches both, : matches only one), then whether you need the u flag, then whether you pasted an over-escaped pattern — \\d from source code should be \d here.

Support on PayPal