Notes › Tech Notes › Regular expressions you cannot read or debug: live match highlighting

Regular expressions you cannot read or debug: live match highlighting

The most expensive regex mistake I have made was a phone number check that looked completely correct: ^1[3-9]\d{9}$. It passed a single test, but when I used it to clean a user list of more than 2,000 rows, it extracted exactly 1. I suspected a wrong quantifier, then invisible characters at the end of lines, then the data itself, before it finally clicked: in multiline text, $ by default matches only the end of the whole string, meaning the end of the last line, so the first 1,999 lines were all blocked by the anchor. A single flag was enough to destroy my trust in that whole afternoon's cleaning results. The hard part of regex is not the number of symbols but the invisible path rules between what you think it matches and what it actually matches.

1. Nail down the most common symbols first

SyntaxMeaningCommon pitfall
. \d \w \sAny single character except newline / digit / letter, digit or underscore / whitespaceAn unescaped . matches anything, not just a period, so the dots in an IP address must be written \.
* + ?Zero or more / one or more / zero or oneAfter a quantifier ? means lazy; after a parenthesis it means a named group or an assertion, a completely different meaning
{n} {n,m}Exactly n times / n to m times{0,} is equivalent to *, but reads better
^ $Start / end of the string (not of a line by default)Forget the m flag on multiline text and everything dies; that was the root cause of my incident
[abc] [^abc]Character set / negated setInside square brackets . * + lose their special meaning, and a misplaced - becomes a range
(a|b) (?:a|b)Capturing group / non-capturing groupWhen the parentheses only exist so | does not split things, use (?:), or you waste a group number

2. Two examples of how flags change the result

Phone number (validate the whole input): ^1[3-9]\d{9}$
Phone number (extract from multiline logs): (?m)^1[3-9]\d{9}$  or  1[3-9]\d{9}(?!\d)
IPv4: \b(?:\d{1,3}\.){3}\d{1,3}\b

The first line only suits form validation: the input box holds exactly one value and ^ and $ bracket it precisely. The second line is what I should have written for the list: m turns ^ $ into start and end of line; or skip the anchors and use (?!\d) to ensure no digit follows the 11 digits, so you do not cut a fake phone number out of a 19-digit order number. In the third line, if \. is written as ., then 123456789 matches as well, which is the most common source of false positives when pulling IPs out of logs.

Of the flags, g (global) decides whether matching stops at the first hit or walks the whole string, i ignores case, m enables multiline, and s lets . match newlines too. These four are the only ones I keep switching in real work.

3. Greedy versus lazy: why it swallowed a whole HTML line

Quantifiers are greedy by default and consume as much as possible. Against a line with two tags such as <a href="/home">text</a> <a href="/about">, <a href=".*"> runs from the first " all the way to the last ", with the </a> in between swallowed whole. Switch to <a href=".*?"> and it closes at the nearest match. Likewise, the more robust habit when scraping HTML is to exclude the terminator itself: <a href="[^"]*"> expresses "up to the next quote" with a character set, which is less error-prone than relying on laziness.

My routine for tuning expressions like this: as soon as it is written, paste the regex and a real sample into Regex Tester and watch the live highlight to see where the boundaries actually fall. Running .*? through your head is unreliable; once the highlight box appears the answer is settled. The groups panel also lists the number and content position of every capture group, so you do not count parentheses when writing $1 or $2 replacements.

4. Three ways a regex turns into an incident

  • Catastrophic backtracking: nested quantifiers such as (a+)+b or (\d+[-\s]*)*@ try exponentially many paths on text that almost matches but ultimately fails. Once in production a single input maxed out the CPU of an endpoint, and tracing it led to ([\w\.-]+)+@ in a cleaning rule; after replacing it with [\w.-]+@, the same input went from over 8 seconds to tens of milliseconds.
  • Validation with no limit on the ending: ^[\w.-]+@[\w-]+\.\w+$ lets through a@b.c on a domain that does not exist. When validating email addresses I treat "legal format" and "actually deliverable" as two different things, and the regex only covers the first.
  • Do not hand-write patterns for bulk work: if all you need is to pull fixed types like phone numbers, email addresses, URLs and IPs out of text, Text Extractor saves effort compared with writing the pattern yourself; for bulk replacement use Batch Find and Replace, which accepts several rules at once and shows how many replacements each one made.

5. Common questions

Q: Why is my regex correct in JS but wrong in Java or Python on the backend?

Mostly dialect differences. The default \d in JS and Java only accepts ASCII digits, while Python 3 extends it to fullwidth digits and digits from other scripts, so cleaning mixed data gives different results; lookbehind such as (?<=) throws a syntax error outright on older Safari; Python's re.match only tries from the start of the string while re.search scans the whole string, and JS test/exec do not anchor without ^. When moving a pattern across languages, run at least three samples: a normal value, a boundary value and a longer dirty string.

Q: What is the difference between a capture group and a non-capturing group (?:)?

Parentheses take a number, and $1 in a replacement refers to them in order; when you only need the grouping and not the content, write (?:...) to avoid numbering slips and get slightly better performance.

Q: How do I know which group I am looking at in the matched content?

Count opening parentheses from left to right starting at 1. With nesting it is very easy to miscount, so I read the actual content of each group in the test tool's group details instead of doing mental arithmetic.

Q: Can a regex validate password strength or amount formats?

Yes, but do not expect one expression to do it all. For amounts, the easiest thing to miss is decimal places combined with thousands separators; two independent assertions are easier to maintain than one very long expression, and easier to diagnose when they fail.

Q: Why does text that looks identical fail to match?

Usually invisible characters: fullwidth spaces, zero-width spaces, the \r of Windows line endings, and soft hyphens picked up when copying from a web page. Look at the characters before blaming the syntax.

The data from that list cleanup did come back correct in the end, but I wrote "anchors do not apply to every line by default" on the first page of my notes. With regex, understanding what it matches is only the passing grade; being able to say where it stops and why it stops is what makes an expression trustworthy.

📎 Related articles

PNG compressed three times only lost 426 bytes: the real boundary of lossless compression 2026-10-22 How Many Times Bigger a PNG Gets After Converting From WebP: When It Is Unavoidable and How to Keep the Growth Down 2026-10-22 Three Reasons the Small Text Smudges When a Screenshot Is Shrunk to a Quarter 2026-10-22 Cropped to the Same 300x300, Why Does the Avatar Always Sit Half a Cell Low-Right 2026-10-22 Forecasting 18 months at 1.7 times a month: why the script and the sheet give two numbers 2026-10-22