RegexCalc Regex Engine

Your Regex Was Fine: $ and . Mean Different Things in Every Engine

RegexCalc — Clean, Modern Regular Expression Calculator & Tester Guides · Updated 2026-10-13 · All guides

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

EngineVersiona$ matches a\n?
JavaScript (V8)Node v24.21.0no
Python re3.12.3yes
Go regexp (RE2)go1.27.2 linux/arm64no
GNU grep3.11sees 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):

CommandCRLF fileLF file
grep -c 'a$'01
grep -cE 'a$'01
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)10
grep -cE $'a\r?$' (ANSI-C quoted)11
grep -cP 'a\r?$'11
grep -cU 'a$' (--binary)0—
sed 's/\r$//' | grep -c 'a$'11
tr -d '\r' | grep -c 'a$'11

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 \r is not a carriage return, and $ is pinned to end-of-line where grep has already stripped the terminator — so the \r is unreachable by anchoring.
  • --binary / -U does not help. It is 0, same as the plain pattern. -U stops 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

NeedJavaScriptPythonGo RE2grep
tolerate a trailing newlinea\n?$a\n?$ or \Za\n?$not needed (per-line)
tolerate CRLFa\r?\n?$a\r?\n?$a\r?\n?$-P 'a\r?$', or tr -d '\r'
. must cross a newline/a.b/sre.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:

  • m is not s. /a.b/m on "a\nb" is false. m changes only ^ and $; /a$/m on "a\n" is TRUE precisely 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" is TRUE, and (?s)a.b on "a\nb" is TRUE.

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

  1. Normalise once at the boundary — s.trimEnd(), s.rstrip("\r\n") — instead of teaching every pattern about the trailing byte.
  2. 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.
  3. If a pattern must behave identically in all of them, write [\s\S] and skip the flag debate.
  4. In grep, treat CRLF as a file-encoding problem, not a regex problem.
  5. 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.

Developer Sponsor / Partner
Copied to clipboard!