Your Regex Was Fine: $ and . Mean Different Things in Every Engine
Every engine below was asked the same question about the same bytes. Three of them gave a different answer, and none of them is wrong.
The test
Input is two bytes: a then \n. Pattern is a$.
| Engine | Version | a$ matches a\n? |
|---|---|---|
| JavaScript (V8) | Node v24.21.0 | no |
Python re | 3.12.3 | yes |
Go regexp (RE2) | go1.27.2 linux/arm64 | no |
| GNU grep | 3.11 | sees a only — the newline is the line separator |
Python is the outlier, and it is the engine most people learned $ from. In Perl and Python, $ matches at end of string or immediately before a final newline. In JavaScript and Go's RE2 it means end of input, full stop. Go documents this deliberately: its $ behaves like Perl's \z.
The port that breaks is the one nobody reviews:
# Python: passes. The trailing newline is forgiven.
assert re.search(r"^[\w.-]+@[\w.-]+\.\w+$", email + "\n")
// JavaScript: same bytes, same pattern, now false.
// A file read with fs.readFile, a fetch body, a clipboard paste -- all
// routinely carry that one byte.
/^[\w.-]+@[\w.-]+\.\w+$/.test(email + "\n"); // false
Validation that passes the test suite and rejects real input in production is the expensive version of this bug. The cheap version is a trim() you will eventually delete and reintroduce.
The \r\n variant, which is worse
a$ against a\r\n is false in every engine measured — Node, Python, Go. The \r is an ordinary character to all three, so the string does not end where you think it ends.
GNU grep agrees, and this is where the usual advice fails. On a two-line CRLF file (a\r\nb\r\n):
| Command | CRLF file | LF file |
|---|---|---|
grep -c 'a$' | 0 | 1 |
grep -cE 'a$' | 0 | 1 |
grep -c 'a\r$' (BRE backslash-r) | 0 | — |
grep -c 'a\r\{0,1\}$' (BRE interval) | 0 | — |
grep -c $'a\r' (literal CR in pattern) | 1 | 0 |
grep -cE $'a\r?$' (ANSI-C quoted) | 1 | 1 |
grep -cP 'a\r?$' | 1 | 1 |
grep -cU 'a$' (--binary) | 0 | — |
sed 's/\r$//' | grep -c 'a$' | 1 | 1 |
tr -d '\r' | grep -c 'a$' | 1 | 1 |
Two things worth saying plainly, because tldr pages get them wrong:
- BRE cannot express this match.
a\r\{0,1\}$returns 0 on CRLF. In BRE\ris not a carriage return, and$is pinned to end-of-line where grep has already stripped the terminator — so the\ris unreachable by anchoring. --binary/-Udoes not help. It is 0, same as the plain pattern.-Ustops grep treating the input as binary; it does not put the newline back into the line.
The spellings that actually work are grep -P 'a\r?$', or an ANSI-C quoted literal CR ($'a\r') which matches CRLF and, note, fails on LF. If you need one command for mixed input, normalise first: tr -d '\r'.
\s$ looks like a fix and is a lie. Measured in Node and Python it matches — and the match is "\n" alone. It matched the whitespace you were trying to tolerate instead of the line you were trying to anchor, so downstream .group(0)/m[0] hands you a newline where you expected your token.
The fix, per engine
| Need | JavaScript | Python | Go RE2 | grep |
|---|---|---|---|---|
| tolerate a trailing newline | a\n?$ | a\n?$ or \Z | a\n?$ | not needed (per-line) |
| tolerate CRLF | a\r?\n?$ | a\r?\n?$ | a\r?\n?$ | -P 'a\r?$', or tr -d '\r' |
. must cross a newline | /a.b/s | re.DOTALL | (?s) or [\s\S] | impossible: see below |
Measured fixes, not recalled ones: /a\n?$/ on "a\n" → TRUE match="a\n", and /a\r?\n?$/ on "a\r\n" → TRUE match="a\r\n".
[\s\S] is the only spelling that behaves identically across JavaScript, Python, and Go, which is why it survives in shared code and in transpiled output. Measured: /a[\s\S]b/ on "a\nb" → TRUE, with no flag anywhere.
Two parts people guess wrong:
mis nots./a.b/mon"a\nb"is false.mchanges only^and$;/a$/mon"a\n"isTRUEprecisely because$now sees a line end.- Go's
(?s)flips.and does not touch$.(?s)a$on"a\n"is still false. It can only match a newline between two anchors:a(?s:.)$on"a\n"isTRUE, and(?s)a.bon"a\nb"isTRUE.
The log-grep mistake
On the two-line input x200 y\nz500 w, /200.*500/ returns null, and /200.*500/s returns "200 y\nz500". In grep, 200.*500 matches nothing at all — and there is no flag that makes . cross a line, because the line is the unit of the tool. grep and sed never see the terminator, so no pattern can address it. Cross-line work belongs to grep -Pzo or pcregrep, not to .*.
Do this
- Normalise once at the boundary —
s.trimEnd(),s.rstrip("\r\n")— instead of teaching every pattern about the trailing byte. - Never assume the engine you are reading from is the engine you are writing for. Regexes get copied out of Python test files into JS handlers daily.
- If a pattern must behave identically in all of them, write
[\s\S]and skip the flag debate. - In
grep, treat CRLF as a file-encoding problem, not a regex problem. - When a match goes missing, test the invisible byte first:
Buffer.from(s).toString("hex")costs one line and settles the argument.
Related: regex gotchas, PCRE syntax JavaScript accepts, the cheat sheet.
Every cell above came from a throwaway probe run on this machine on 2026-10-13 (Node v24.21.0, Python 3.12.3, go1.27.2, GNU grep 3.11), not from documentation. Rows the probes could not produce cleanly were deleted rather than estimated — notably \k<name> backreferences and v-flag set operations, both of which failed in ways the harness could not distinguish from my own transcription errors.