Find and Replace: The Regex Features That Bite, Demonstrated One by One
Published 5/30/2025 · 14 min read · Text & language tools
Daniel Okonkwo — Front-end developer and tech writer at Allin
Web performance · File formats
Checked against 5 sources
The ways a find-and-replace goes wrong are specific and reproducible, so they can be demonstrated rather than warned about. Quantifiers are greedy by default: on "<b>bold</b> and <i>italic</i>", replacing /<.+>/g with nothing returns an empty string, because .+ runs to the last > in the line; the lazy /<.+?>/g and the negated /<[^>]+>/g both return "bold and italic". The dot never matches a newline unless the s flag is set: /Price.*units/ is false across two lines and /Price.*units/s is true. The replacement string has its own syntax, unrelated to the pattern: $& inserts the whole match, $1 a group, and $$ is the only way to emit a literal dollar — "$$$1" on "Total: 42 USD" gives "Total: $42" while "$$&" gives the literal "$&". A regex object with the g flag keeps a lastIndex between calls, so reusing one across rows skips matches: the same /\d+/g tested against three order lines returned true, false, true. And /i is pure case folding, not language knowledge: /i/i does not match İ, and /I/i does not match ı. Count the matches first, then replace.
Greedy against lazy on the same string, the dot that skips newlines, $& and $$ in the replacement, a reused /g regex that silently skips a row, and why /i knows nothing about Turkish i. Every failure run in Node, with a count-then-replace routine that catches them.
Greedy and lazy on the same input
Take the string <b>bold</b> and <i>italic</i> and try to delete the tags with /<.+>/g. The result is an empty string. The quantifier + is greedy: it takes as much as it can and gives characters back only when the rest of the pattern fails, so .+ swallows everything up to the final > and one single match covers the whole line. The first match of /<.+>/ is literally the entire input.
Two edits fix it and they are not equivalent. Adding a question mark makes the quantifier lazy: /<.+?>/g takes as little as it can, its first match is <b>, and the replacement returns bold and italic. Replacing the dot with a negated class does the same job differently: /<[^>]+>/g cannot cross a > at all, so it returns bold and italic too. Prefer the negated class where one exists — it says what the boundary is instead of relying on the engine's backtracking to stop in the right place, and it does not silently become greedy again when you later add an optional part to the pattern.
The dot stops at the end of the line
In JavaScript, . matches any character except a line terminator. Take a two-line record: "Price: $12.50" then a newline then "Stock: 3 units". The test /Price.*units/ returns false. The same pattern with the s flag, /Price.*units/s, returns true, because s — dotAll — removes the line-terminator exception. The older workaround, a character class that covers everything, /Price[\s\S]*units/, returns true too and works in runtimes that predate the flag.
This is where a replace goes wrong quietly rather than loudly. A pattern that was meant to span a paragraph simply finds nothing, the replace count comes back as zero, and a pipeline that does not check the count reports success. It is also the reason /.+/g is a usable line splitter: on the same two-line record it returns two matches, one per line, which is a feature when you want it and a surprise when you do not.
The replacement string has its own syntax
The second argument of replace is not plain text. It is a small language, and its only metacharacter is $. On "Total: 42 USD" with /(\d+) (USD|EUR)/, the replacement [$&] gives "Total: [42 USD]", $1 gives "Total: 42", and $<amount> — with the group named in the pattern — gives "Total: [42]". The full list is short: $&, $1 to $99, $<name>, $` for everything before the match, $' for everything after, and $$ for a literal dollar.
Two traps follow from that. The first is that the $ of a replacement is not the $ of a pattern: in a pattern, $ is an anchor meaning end of input, and in a replacement it never means that. Writing $ where you want a currency sign produces a literal $ only by luck — replace(/USD/, "$") does give "Total: 42 $", because a lone $ followed by nothing special is passed through, but as soon as it is followed by a digit or an ampersand the meaning changes. Write $$ and stop thinking about it.
The second trap is ambiguity by digit. $10 means group 10 when the pattern has ten capture groups, and group 1 followed by the character 0 when it does not: on a pattern with ten groups the replacement [$10] returned [j], and on a pattern with one group it returned [a0]. The meaning of your replacement string therefore depends on how many groups the pattern happens to contain — add a group and the replacement changes behaviour without being edited. Named groups remove the ambiguity entirely, and if the replacement is data rather than code, pass a function: a function's return value is inserted verbatim, so replace(/\d+/, () => "$&") on "Total: 42 USD" produces the literal "$&" instead of the matched number.
A /g regex remembers where it stopped
A RegExp object with the g flag carries a mutable lastIndex property, and .test() and .exec() both read it and write it. Reuse the same object across several strings and it starts each search where the previous one ended. Take const re = /\d+/g and test it against three rows — "order 12", "order 7", "order 349" — and the results are true, false, true. The middle row has a number in it. It was skipped because lastIndex was 8 when the search on it began, and after the third call lastIndex was 9.
The same effect appears on a single string. Four consecutive re.test("banana") calls with /a/g return true, true, true, false: three a's, then a failed search that resets lastIndex to 0, so a fifth call would return true again. Nothing about this is a bug — it is what makes exec() usable in a while loop — but it makes a module-level regex constant a shared mutable variable in disguise.
Three fixes, in order of preference. Do not put g on a regex you use with .test(): the flag adds nothing there and causes everything above. Use matchAll or match with g when you want every occurrence, since both handle the index for you. Or, if you must reuse an object, set re.lastIndex = 0 before each search, and know that you have chosen the fragile option.
Case-insensitive is not language-aware
The i flag applies Unicode case folding, which is a fixed table, not a locale rule. The clearest counter-example is Turkish, where the alphabet has two i's: dotted i/İ and dotless ı/I. Run the tests and /i/i does not match İ, and /I/i does not match ı — both return false. Meanwhile "I".toLowerCase() returns "i" but "I".toLocaleLowerCase("tr") returns "ı", and "i".toLocaleUpperCase("tr") returns "İ". The case functions can be told about a locale. The regex flag cannot.
Three more results from the same run, each of which has cost somebody a morning. /ss/i does not match ß and /ß/i does not match SS, because case folding is a one-to-one character mapping and the German sharp s expands to two letters. /k/i does not match the Kelvin sign K, but /k/iu — with the Unicode flag — does, because the u flag switches on simple case folding, so adding a flag that looks purely syntactic changes which characters are considered equal. And /é/i does not match a decomposed É, an e followed by a combining acute: case folding is not normalisation, and the two are independent decisions, as the article on accents in this series works through in more detail.
Anchoring, and why \b lies about accented words
\b is not a character. It is a zero-width assertion that succeeds where a word character (in JavaScript: [A-Za-z0-9_]) sits on exactly one side. That definition is pure ASCII, and it produces a result that is exactly backwards for any language with diacritics. /\bcafé\b/ does not match "un café noir", because after é — which is not a word character — comes a space, which is not one either, so there is no boundary. The same pattern does match "un cafés", because é is followed by s and that is a boundary. The assertion fires where the word continues and fails where it ends.
The replacement for \b is a pair of Unicode lookarounds: /(?<![\p{L}\p{N}])café(?![\p{L}\p{N}])/u matches "un café noir" and refuses "un cafés", which is what a person means by a whole word. The u flag is required for \p{…} to be recognised at all. Where the word you are replacing is pure ASCII, \b remains fine — /\bcat\b/ behaves — but the moment the pattern contains a letter outside A–Z, check both ends by hand.
Count, then replace: a worked example
Take the line: cat, category, concatenate, the cat sat. The job is to replace the animal with dog. Run the count first: "cat, category, concatenate, the cat sat".match(/cat/g).length is 4. You expected 2. That single number, read before anything was written, is the whole safeguard — and if you had replaced instead of counting, the output would have been "dog, dogegory, condogenate, the dog sat".
Anchor and count again: /\bcat\b/g finds 2, which is the number you expected, and only then is it safe to replace. The result is "dog, category, concatenate, the dog sat". Afterwards, count once more: /\bcat\b/g must now find 0 and /\bdog\b/g must find 2. Three counts and one replace, four lines in total, and every one of them is a line you can show somebody when they ask what the change did.
One detail from the German version of the same exercise is worth keeping. Replacing Bahn with Zug on "Bahn, Bahnhof, Autobahn, die Bahn fährt" without anchors gives 3 matches, not 4: the pattern is case-sensitive, so it hits Bahnhof but misses the lowercase bahn inside Autobahn. A count that is lower than expected is as informative as one that is higher, and it points at a different bug.
| Token | What it inserts | Replacement written | Result |
|---|---|---|---|
| $& | The whole match | [$&] | Total: [42 USD] |
| $1 | Capture group 1 | $1 | Total: 42 |
| $$ | One literal dollar sign | $$$1 | Total: $42 |
| $10 | Group 10 if it exists, otherwise group 1 then the character 0 | $10 | Total: 420 |
| $` and $' | Everything before, everything after the match | <$`> | Total: <Total: > |
| $<name> | A named group; left literal if the pattern has no named groups | [$<amount>] | Total: [42] |
Frequently asked questions
- Why did my replace delete a whole line?
- Almost always a greedy quantifier between two delimiters that appear more than once. /<.+>/ on a line with two tags matches from the first < to the last >, so the single match is the whole line. Add a question mark to make the quantifier lazy, /<.+?>/, or better, forbid the closing delimiter inside the match with a negated class, /<[^>]+>/.
- Why does the same regex give a different answer the second time I call it?
- Because it has the g flag and a lastIndex that survives between calls. /a/g tested four times against "banana" returns true, true, true, false. Drop the g flag when you only want a yes-or-no answer, use matchAll when you want every occurrence, or reset re.lastIndex = 0 before each search if the object really must be shared.
- How do I put a literal dollar sign in the replacement?
- Write $$. A single $ works only when nothing special follows it, which makes it a bug waiting for the next edit: "$$$1" on "Total: 42 USD" gives "Total: $42", while the intuitive "$$&" gives the literal "$&" rather than the match. If the replacement text is data — a value from a form, a translation, anything you did not type — pass a function instead of a string, because a function's return value is inserted without any $ processing at all.
- Does the i flag understand accents and other alphabets?
- It understands case folding and nothing else. It will equate a with A across most of Unicode, but it does not normalise, so /é/i fails on a decomposed É; it does not expand, so /ss/i fails on ß; and it does not know any locale, so /i/i fails on the Turkish İ. If you need any of those, normalise the text first and use a collator with sensitivity "base" for comparison, rather than expecting the flag to grow language knowledge it never had.
- How do I match across two lines?
- Add the s flag, which makes the dot match line terminators as well: /Price.*units/ is false across two lines and /Price.*units/s is true. Do not confuse it with m, which is about anchors rather than the dot: m makes ^ and $ match at the start and end of each line instead of the whole input, and leaves the dot exactly as it was. You often want both, and they are independent.
- Why does \b fail on a word ending in an accented letter?
- Because \b is defined against the ASCII word class [A-Za-z0-9_], and an accented letter is not in it. A boundary needs a word character on exactly one side, so between é and a following space there is none, and /\bcafé\b/ does not match "un café noir" — while it does match "un cafés", where é is followed by an ASCII s. Replace the assertions with Unicode lookarounds: /(?<![\p{L}\p{N}])café(?![\p{L}\p{N}])/u, remembering that \p{…} needs the u flag.
Articles you may find interesting
All guides →Related tools
Sources
- Ecma International — ECMAScript Language Specification — RegExp objects, the lastIndex property, and String.prototype.replace (the GetSubstitution algorithm)
- MDN Web Docs — String.prototype.replace() — specifying a string or a function as the replacement
- MDN Web Docs — Regular expressions — quantifiers, assertions and the d, g, i, m, s, u, v, y flags
- Unicode Consortium — Unicode Technical Standard #18: Unicode Regular Expressions
- Unicode Consortium — Unicode Standard Annex #44: Unicode Character Database — case folding and the SpecialCasing data for Turkish i
Spotted a mistake in this article?