creatorvalet

Regex replace

The replacement side is its own language. What $1, $&, $$ and a replacer function do, and where a missing flag costs every match but the first.

Updated 2026-08-09

People spend their learning time on the pattern and almost none on the second argument. That is backwards. Once a pattern matches, the search half of the job is finished — and the replacement half has its own syntax, its own escape character, its own idea of how many matches to touch, and a security bug that only exists on that side. None of it is regex. It is a separate miniature language that happens to sit next to one, and if you have already mapped the pattern side with the cheat sheet, this is the column it does not have room for.

How regex replace decides how many

Start with the one that costs the most time, because it produces no error at all.

"a-b-c".replace(/-/, ":")    // "a:b-c"
"a-b-c".replace(/-/g, ":")   // "a:b:c"

One missing letter, and two of your three hyphens survive. JavaScript’s String.replace stops after the first match unless the pattern carries the g flag, and this is a genuine outlier: PHP’s preg_replace and Python’s re.sub both replace every occurrence by default and want an explicit limit if you only meant one. Somebody translating a working line of Python into JavaScript will hit this on the first try, and the output looks close enough to right that it survives a glance.

replaceAll exists to take the decision away, and it is stricter than most people expect:

"a-b-c".replaceAll("-", ":")   // "a:b:c"
"a-b-c".replaceAll(/-/, ":")   // TypeError

The error reads String.prototype.replaceAll called with a non-global RegExp argument. It refuses the non-global pattern rather than quietly upgrading it, which is the right call — replaceAll with a pattern that says “first only” is a contradiction, and silently picking a winner would hide the mistake instead of naming it.

The dollar signs, one at a time

Inside a replacement string, $ is the escape character. Everything else is literal text.

"2026-08-09".replace(/(\d+)-(\d+)-(\d+)/, "$3/$2/$1")   // "09/08/2026"
Token Inserts Example against cat and /a/
$1$99 The numbered capture group
$<name> A named capture group
$& The whole match "[$&]" gives c[a]t
$` Everything before the match "[$`]" gives c[c]t
$' Everything after the match "[$']" gives c[t]t
$$ One literal dollar sign "$$5" gives $5

The two obscure ones are worth a look precisely because they are obscure. $` and $' give you the text on either side of the match, which is occasionally exactly what you want and is otherwise a fine way to duplicate a document by accident.

The rule for anything else is pass it through untouched. "ab".replace(/(a)/, "$2") returns $2b, because there is no second group and the engine has no opinion about that — it copies the two characters and moves on. So a replacement built by string concatenation can carry a stray $ into the output without ever raising an error.

Named groups use angle brackets on this side, and the numbered form keeps working alongside them:

"2026-08-09".replace(
  /(?<y>\d+)-(?<m>\d+)-(?<d>\d+)/,
  "$<d>/$<m>/$<y>"
)   // "09/08/2026"

A function instead of a string

The moment a replacement needs a decision — arithmetic, a lookup, a conditional — the string form runs out and you hand over a function instead. It is called once per match, and its return value is used literally.

"3 apples and 12 pears".replace(/\d+/g, (m) => String(Number(m) * 2))
// "6 apples and 24 pears"

The arguments arrive in a fixed order that surprises people the first time: the whole match, then one argument per capture group, then the offset, then the entire subject string, and — only when the pattern has named groups — an object of them at the end. Measured against /(?<y>\d{4})-(?<m>\d{2})/ and the string 2026-08, a replacer receives "2026-08", "2026", "08", 0, "2026-08", and { y: "2026", m: "08" }.

Because the group count decides how many arguments come before the offset, reading them by position is fragile. Adding one group to the pattern moves the offset along by one and breaks a replacer that indexed it directly. Destructure what you need from the front, or accept a rest parameter and take the named object off the end.

When the replacement text comes from a variable

This is the part with a real bug in it, and it is easy to miss because it needs user input to show up.

const supplied = "$&$&$&"
"ax".replace(/x/, supplied)         // "axxx"   ← expanded
"ax".replace(/x/, () => supplied)   // "a$&$&$&" ← literal

Any string that reaches the second argument is parsed for dollar tokens, including one that came from a form field, a config file or a translation bundle. Someone who can influence that string can duplicate the match, splice in the text before it, or pull in the whole document after it — a small injection with no obvious name, which is why it survives code review.

The fix is one line: wrap the value in a function. A replacer’s return value is inserted verbatim and never scanned for tokens, so () => supplied is not a style preference, it is the safe form.

The same idea in three spellings

Every engine invented its own notation for “put group one here”, and none of them agree.

Want JavaScript PHP / PCRE Python sed
Group 1 $1 $1 or \1 \1 or \g<1> \1
Group 1, then a digit ambiguous ${1} \g<1>
By name $<name> not supported \g<name>
The whole match $& $0 \g<0> &
Every match /g flag default default s///g

Two of those rows are measured surprises rather than trivia. PHP does not support a named back-reference in the replacement at all: preg_replace('/(?<a>\d+)-(?<b>\d+)/', '$b/$a', …) leaves the literal text $b/$a in the output, so a pattern with beautifully named groups still has to be dereferenced by number.

The ambiguous row is the reason the other engines grew an unambiguous spelling. What does $12 mean — group twelve, or group one followed by the character 2? JavaScript decides by looking at how many groups the pattern actually has: with one group "a".replace(/(a)/, "$12") gives a2, and with twelve groups the same replacement returns group twelve. The meaning of the replacement string therefore changes when you add a group to the pattern. PHP writes ${1} to settle it — preg_replace('/(\d+)(\d)/', '${1}x', '089') gives 08x — and Python writes \g<1>.

Seeing the result before you ship it

A replacement is one of the few things in programming that is genuinely faster to check than to reason about, because the output either reads correctly or it does not. The regex tester has a replacement field that runs the engine’s real replace rather than a simulation of it, so $1, $<name> and $& behave there exactly as they will in your source, and the match list underneath shows you which occurrences were touched — the fastest way to catch a missing g before it reaches a file.

If the job is not really a substitution but a cleanup — straightening smart quotes, removing zero-width characters, rejoining the line breaks a paste dragged in — the text cleaner does those as named operations with a count beside each one, so you can see what is broken before deciding what to touch. That is usually a better trade than writing a throwaway pattern whose only purpose is to be deleted afterwards.