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.
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.