Regular Expressions Module 8 – Flavours compared and regex in each language
Flavours compared: porting a pattern safely
Why one regex gives different answers in JavaScript, Python, Go and PHP, which rows of the flavour matrix break a port, and how a test table proves a move.
What you will learn
- Predict what one replacement template gives in JavaScript, Python, Go and PHP
- Read the rows of the flavour matrix that break a pattern when it moves between engines
- Distinguish a pattern an engine refuses from one it quietly reads as something else
- Rewrite a pattern and its replacement for a second engine by following a five-step checklist
- Prove a move with one test table run in both engines
Before you start
What you’ll learn · Before you start
What you will learn
- Predict what one replacement template gives in JavaScript, Python, Go and PHP
- Read the rows of the flavour matrix that break a pattern when it moves between engines
- Distinguish a pattern an engine refuses from one it quietly reads as something else
- Rewrite a pattern and its replacement for a second engine by following a five-step checklist
- Prove a move with one test table run in both engines
Before you start
On this page
Replace the group in (a) with $1x on the text ab. JavaScript answers axb, and so does PHP. Go answers b,
because it reads $1x as a group named 1x, finds none and writes nothing. One line is copied between languages, and
the answer changes without an error.
A regex port: A pattern, replacement template or flag set rewritten for a second regex engine so that it gives the same results as the first on the strings that matter. Copying the text of the pattern is not enough. is a pattern or replacement rewritten so that a second engine gives the same results as the first. This lesson turns it into a method: read the rows of a table where flavour: A particular regular-expression syntax and feature set, such as ECMAScript, Python re, PCRE2 or RE2; a pattern is written for one of them. More in “What a regular expression is and where it runs” disagree, tell a pattern an engine refuses from one it quietly reads another way, follow a five-step checklist, and prove the move with one test table that both engines run. The five lessons after this one each take a single language in depth.
One idea, four spellings of a replacement
A replacement string: The new text given to a replace function. It has its own escapes, so a dollar sign or a backslash in it may name part of the match instead of standing for itself. More in “Writing patterns in code: strings and escaping” has a small syntax of its own, and each language writes it
differently. Take the job “keep group 1 and put the letter x after it”, with the pattern (a) on the text ab. The
JavaScript run lists seven templates and what each one gives. Press Run, then Edit, and try $3x or a template of your
own:
// One idea, "group 1, then the letter x", written as JavaScript replacement templates.
// The subject is 'ab'; the group (a) matches the first letter.
const rows = [
['(a)', '$1x'],
['(a)', '${1}x'],
['(a)', '$10'],
['(a)', '$2x'],
['(a)', '$$1x'],
['(a)', '[$&]'],
['(?<g>a)', '$<g>x'],
];
for (const [pattern, template] of rows) {
const result = 'ab'.replace(new RegExp(pattern), template);
console.log(`${pattern.padEnd(8)} ${template.padEnd(6)} -> ${result}`);
} Output
(a) $1x -> axb
(a) ${1}x -> ${1}xb
(a) $10 -> a0b
(a) $2x -> $2xb
(a) $$1x -> $1xb
(a) [$&] -> [a]b
(?<g>a) $<g>x -> axb
Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node template.mjs
Runs on this device, in your browser. The first run downloads JavaScript (about 0.6 MB), which is kept for the next runs.
Your run, in this browser
$1x is group 1 followed by the letter x, so the result is axb. The template ${1}x, which other languages use to
fence a group number off from what follows, means nothing to JavaScript and is written out as it stands: ${1}xb. The
line for $10 shows the digit rule of ECMA-262: when a two-digit number is larger than the number of groups, the engine
reads the first digit as the group and keeps the second as text, so with one group $10 is group 1 and then 0. A
named group is read with $<g>.
The same job in three other engines:
Python 3.14.8 · template/template.py
"""The same idea, "group 1, then the letter x", as Python replacement templates, on the subject 'ab'."""
import re
rows = [
(r"(a)", "$1x"),
(r"(a)", r"\1x"),
(r"(a)", r"\g<1>x"),
(r"(a)", r"\g<1>0"),
(r"(a)", r"\10"),
(r"(?P<g>a)", r"\g<g>x"),
]
for pattern, template in rows:
try:
result = re.sub(pattern, template, "ab", count=1)
except re.PatternError as error:
result = f"PatternError: {error}"
print(f"{pattern:9} {template:7} -> {result}") Output
(a) $1x -> $1xb (a) \1x -> axb (a) \g<1>x -> axb (a) \g<1>0 -> a0b (a) \10 -> PatternError: invalid group reference 10 at position 1 (?P<g>a) \g<g>x -> axb
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 template.py
Go 1.27.1 · template/template.go
// The same idea, "group 1, then the letter x", as Go replacement templates, on the subject "ab".
package main
import (
"fmt"
"regexp"
)
func main() {
group := regexp.MustCompile(`(a)`)
named := regexp.MustCompile(`(?P<g>a)`)
show := func(pattern, template, result string) {
fmt.Printf("%-9s %-7s -> %s\n", pattern, template, result)
}
show("(a)", "$1x", group.ReplaceAllString("ab", "$1x"))
show("(a)", "${1}x", group.ReplaceAllString("ab", "${1}x"))
show("(a)", "${1}0", group.ReplaceAllString("ab", "${1}0"))
show("(a)", "$10", group.ReplaceAllString("ab", "$10"))
show("(a)", "$$1x", group.ReplaceAllString("ab", "$$1x"))
show("(?P<g>a)", "$gx", named.ReplaceAllString("ab", "$gx"))
show("(?P<g>a)", "${g}x", named.ReplaceAllString("ab", "${g}x"))
show("(a)", "literal $1x", group.ReplaceAllLiteralString("ab", "$1x"))
} Output
(a) $1x -> b
(a) ${1}x -> axb
(a) ${1}0 -> a0b
(a) $10 -> b
(a) $$1x -> $1xb
(?P<g>a) $gx -> b
(?P<g>a) ${g}x -> axb
(a) literal $1x -> $1xb
Recorded with Go 1.27.1 on macOS 26 arm64. To run it yourself: go run template.go
PHP 8.5.11 · template/template.php
<?php
// The same idea, "group 1, then the letter x", as PHP replacement templates, on the subject 'ab'.
$rows = [
['/(a)/', '$1x'],
['/(a)/', '${1}x'],
['/(a)/', '\1x'],
['/(a)/', '${1}1'],
['/(a)/', '$11'],
['/(?P<g>a)/', '${g}x'],
];
foreach ($rows as [$pattern, $template]) {
$result = preg_replace($pattern, $template, 'ab', 1);
printf("%-12s %-6s -> %s\n", $pattern, $template, $result);
} Output
/(a)/ $1x -> axb
/(a)/ ${1}x -> axb
/(a)/ \1x -> axb
/(a)/ ${1}1 -> a1b
/(a)/ $11 -> b
/(?P<g>a)/ ${g}x -> ${g}xb
Recorded with PHP 8.5.11 (cli) (NTS) on macOS 26 arm64. To run it yourself: php template.php
Python takes $1x as plain text and writes $1xb. Its reference is \1x, or \g<1>x when a digit follows, because
\10 names group ten and Python stops with an error; the re documentation gives the \g<...> form for this case. Go
reads a name for as long as it can: its documentation says $1x is the same as ${1x}, so the group 1x does not exist
and the result is empty, b. Braces fix it, as in ${1}x. PHP is the most forgiving, since $1x, ${1}x and \1x
all give axb, but a named group has no template there: ${g}x stays as text, and the PHP manual’s own example shows
the same.
Copying a template as it stands
Wrong: carrying '$1x' from a JavaScript replace call into Go’s ReplaceAllString. It compiles, runs and returns
b. Right: write ${1}x for Go, \g<1>x or \1x for Python, and run the result before trusting it.
Across the four engines the idea is the same and the spelling is not, so the spelling is the part that is checked.
Key idea
A replacement template is code in the language you are moving to: its group numbers, braces and escapes are read by that engine, not by the one you copied them from.
Checkpoint
Check: Replacement templates in each engine
Which rows of the matrix break a port?
A flavour matrix: A table of regex constructs against engines, each cell recording what that engine does with the construct: accept it, refuse it or read it another way. It shows which parts of a pattern need work before a port. is a table of constructs against engines, with what each engine does
in each cell. The table in this section is built from the runs on the page, one row at a time. The largest row, the
scope of \d, \w, \s and \b, has a lesson of its own on shorthand classes;
these are the other rows that cost ports.
The probe program asks one question per row and prints one line for each. A refused pattern prints its error instead of a result. JavaScript runs first, and four more engines follow:
// Eight rows of the flavour matrix, asked of JavaScript. Every row prints one line: what was asked, what came back.
// A pattern the engine refuses prints the name of the error instead of a result.
const attempt = (label, run) => {
let outcome;
try {
outcome = run();
} catch (error) {
outcome = `error ${error.name}`;
}
console.log(`${label.padEnd(28)} ${outcome}`);
};
attempt('b$ on "ab\\n"', () => /b$/.test('ab\n'));
attempt('b\\Z on "ab\\n"', () => /b\Z/.test('ab\n'));
attempt('b\\z on "ab\\n"', () => /b\z/.test('ab\n'));
attempt('replace a* by - in baac', () => 'baac'.replace(/a*/g, '-'));
attempt('split ([,;]) on a,b;c', () => JSON.stringify('a,b;c'.split(/([,;])/)));
attempt('a{,2} in aaa', () => JSON.stringify('aaa'.match(/a{,2}/g)));
attempt('named group (?<n>a)', () => new RegExp('(?<n>a)').source);
attempt('named group (?P<n>a)', () => new RegExp('(?P<n>a)').source);
attempt('lookbehind (?<=a+)b', () => JSON.stringify('aab'.match(/(?<=a+)b/))); Output
b$ on "ab\n" false
b\Z on "ab\n" false
b\z on "ab\n" false
replace a* by - in baac -b--c-
split ([,;]) on a,b;c ["a",",","b",";","c"]
a{,2} in aaa null
named group (?<n>a) (?<n>a)
named group (?P<n>a) error SyntaxError
lookbehind (?<=a+)b ["b"]
Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node rows.mjs
Runs on this device, in your browser. The first run downloads JavaScript (about 0.6 MB), which is kept for the next runs.
Your run, in this browser
Python 3.14.8 · rows/rows.py
"""Eight rows of the flavour matrix, asked of Python's re. Every row prints one line: what was asked, what came back.
A pattern the engine refuses prints the name of the error instead of a result."""
import json
import re
def attempt(label, run):
try:
outcome = run()
if isinstance(outcome, bool):
outcome = "true" if outcome else "false"
except re.PatternError as error:
outcome = f"error PatternError: {error}"
print(f"{label:28} {outcome}")
attempt('b$ on "ab\\n"', lambda: bool(re.search(r"b$", "ab\n")))
attempt('b\\Z on "ab\\n"', lambda: bool(re.search(r"b\Z", "ab\n")))
attempt('b\\z on "ab\\n"', lambda: bool(re.search(r"b\z", "ab\n")))
attempt("replace a* by - in baac", lambda: re.sub(r"a*", "-", "baac"))
attempt("split ([,;]) on a,b;c", lambda: json.dumps(re.split(r"([,;])", "a,b;c")))
attempt("a{,2} in aaa", lambda: json.dumps(re.findall(r"a{,2}", "aaa")))
attempt("named group (?<n>a)", lambda: re.compile(r"(?<n>a)").pattern)
attempt("named group (?P<n>a)", lambda: re.compile(r"(?P<n>a)").pattern)
attempt("lookbehind (?<=a+)b", lambda: re.search(r"(?<=a+)b", "aab")) Output
b$ on "ab\n" true
b\Z on "ab\n" false
b\z on "ab\n" false
replace a* by - in baac -b--c-
split ([,;]) on a,b;c ["a", ",", "b", ";", "c"]
a{,2} in aaa ["aa", "a", ""]
named group (?<n>a) error PatternError: unknown extension ?<n at position 1
named group (?P<n>a) (?P<n>a)
lookbehind (?<=a+)b error PatternError: look-behind requires fixed-width pattern
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 rows.py
Go 1.27.1 · rows/rows.go
// Eight rows of the flavour matrix, asked of Go's regexp. Every row prints one line: what was asked, what came back.
// A pattern the engine refuses prints the error Compile returned instead of a result.
package main
import (
"encoding/json"
"fmt"
"regexp"
)
func attempt(label, pattern string, run func(re *regexp.Regexp) string) {
re, err := regexp.Compile(pattern)
outcome := ""
if err != nil {
outcome = "error " + err.Error()
} else {
outcome = run(re)
}
fmt.Printf("%-28s %s\n", label, outcome)
}
func list(values []string) string {
text, _ := json.Marshal(values)
return string(text)
}
func main() {
attempt(`b$ on "ab\n"`, `b$`, func(re *regexp.Regexp) string { return fmt.Sprint(re.MatchString("ab\n")) })
attempt(`b\Z on "ab\n"`, `b\Z`, func(re *regexp.Regexp) string { return fmt.Sprint(re.MatchString("ab\n")) })
attempt(`b\z on "ab\n"`, `b\z`, func(re *regexp.Regexp) string { return fmt.Sprint(re.MatchString("ab\n")) })
attempt("replace a* by - in baac", `a*`, func(re *regexp.Regexp) string { return re.ReplaceAllString("baac", "-") })
attempt("split ([,;]) on a,b;c", `([,;])`, func(re *regexp.Regexp) string { return list(re.Split("a,b;c", -1)) })
attempt("a{,2} in aaa", `a{,2}`, func(re *regexp.Regexp) string { return list(re.FindAllString("aaa", -1)) })
attempt("named group (?<n>a)", `(?<n>a)`, func(re *regexp.Regexp) string { return re.String() })
attempt("named group (?P<n>a)", `(?P<n>a)`, func(re *regexp.Regexp) string { return re.String() })
attempt("lookbehind (?<=a+)b", `(?<=a+)b`, func(re *regexp.Regexp) string { return list(re.FindAllString("aab", -1)) })
} Output
b$ on "ab\n" false
b\Z on "ab\n" error error parsing regexp: invalid escape sequence: `\Z`
b\z on "ab\n" false
replace a* by - in baac -b-c-
split ([,;]) on a,b;c ["a","b","c"]
a{,2} in aaa null
named group (?<n>a) (?<n>a)
named group (?P<n>a) (?P<n>a)
lookbehind (?<=a+)b error error parsing regexp: invalid named capture: `(?<=a+)b`
Recorded with Go 1.27.1 on macOS 26 arm64. To run it yourself: go run rows.go
PHP 8.5.11 · rows/rows.php
<?php
// Eight rows of the flavour matrix, asked of PHP's preg functions (PCRE2). Every row prints one line: what was
// asked, what came back. A pattern PCRE2 refuses prints the warning text and "false".
set_error_handler(function (int $level, string $message): bool {
echo " warning: ", preg_replace('/^[a-z_]+\(\): /', '', $message), "\n";
return true;
});
function attempt(string $label, callable $run): void
{
$outcome = $run();
if (is_bool($outcome)) {
$outcome = $outcome ? 'true' : 'false';
}
printf("%-28s %s\n", $label, $outcome);
}
attempt('b$ on "ab\n"', fn() => preg_match('/b$/', "ab\n") === 1);
attempt('b\Z on "ab\n"', fn() => preg_match('/b\Z/', "ab\n") === 1);
attempt('b\z on "ab\n"', fn() => preg_match('/b\z/', "ab\n") === 1);
attempt('replace a* by - in baac', fn() => preg_replace('/a*/', '-', 'baac'));
attempt('split ([,;]) on a,b;c', fn() => json_encode(preg_split('/([,;])/', 'a,b;c')));
attempt('a{,2} in aaa', fn() => json_encode(preg_match_all('/a{,2}/', 'aaa', $found) ? $found[0] : null));
attempt('named group (?<n>a)', fn() => preg_match('/(?<n>a)/', 'a') === 1 ? '(?<n>a)' : 'no');
attempt('named group (?P<n>a)', fn() => preg_match('/(?P<n>a)/', 'a') === 1 ? '(?P<n>a)' : 'no');
attempt('lookbehind (?<=a+)b', fn() => preg_match('/(?<=a+)b/', 'aab') === 1); Output
b$ on "ab\n" true
b\Z on "ab\n" true
b\z on "ab\n" false
replace a* by - in baac -b--c-
split ([,;]) on a,b;c ["a","b","c"]
a{,2} in aaa ["aa","a",""]
named group (?<n>a) (?<n>a)
named group (?P<n>a) (?P<n>a)
warning: Compilation failed: length of lookbehind assertion is not limited at offset 0
lookbehind (?<=a+)b false
Recorded with PHP 8.5.11 (cli) (NTS) on macOS 26 arm64. To run it yourself: php rows.php
PCRE2 10.49 · rows/rows_pcre2.py
"""The pattern rows of the matrix in PCRE2 10.49, through pcre2test. A matched line starts with " 0:"; "No match"
means the pattern compiled and found nothing; "Failed:" means the pattern was refused."""
from pcre2_session import run
run(r'''
/b$/
ab\n
/b\Z/
ab\n
/b\z/
ab\n
/a{,2}/g
aaa
/(?<n>a)/
a
/(?P<n>a)/
a
/(?<=a+)b/
aab
''') Output
$ pcre2test -q patterns.txt (PCRE2 10.49)
/b$/
ab\n
0: b
/b\Z/
ab\n
0: b
/b\z/
ab\n
No match
/a{,2}/g
aaa
0: aa
0: a
0:
/(?<n>a)/
a
0: a
1: a
/(?P<n>a)/
a
0: a
1: a
/(?<=a+)b/
Failed: error 125 at offset 0: length of lookbehind assertion is not limited
here: |-->| (?<=a+)b
aab
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 rows_pcre2.py
Read side by side, the lines make this table. PHP’s preg functions are built on the PCRE2 library, and PHP 8.5.11 bundles PCRE2 10.44, so its column agrees with the PCRE2 10.49 tab wherever both ran.
| Row | JavaScript | Python | Go | PHP |
|---|---|---|---|---|
b$ on ab and a final newline |
no match | match | no match | match |
\Z |
the letter Z | the very end | refused | the end, or before a final newline |
\z |
the letter z | the very end | the very end | the very end |
a{,2} |
literal text | up to two a | literal text | up to two a |
(?<n>a) |
accepted | refused | accepted | accepted |
(?P<n>a) |
refused | accepted | accepted | accepted |
(?<=a+)b |
accepted | refused: fixed width only | refused | refused: length not limited |
a* replaced by - in baac |
-b--c- |
-b--c- |
-b-c- |
-b--c- |
split on ([,;]) |
keeps the separators | keeps them | drops them | drops them |
The rows fall into three kinds. Some are about grammar: the two named-group spellings and the unbounded lookbehind are
refused by one engine and accepted by another, so the failure shows at once. Some are about meaning: the dollar sign and
{,2} compile everywhere, \Z everywhere except Go, and they differ in what they match, which is the trailing-newline trap: A validation bug: a pattern such as ^abc$ accepts abc followed by a line feed in engines whose $ also holds before a final newline, so the checked value still carries the extra character. More in “Anchors: start, end and multiline mode” of anchors and line modes seen from another side.
The last two rows are about output: the pattern means the same thing, yet the engines report a different list or string.
The empty-match row has an official reason. Go’s documentation says that empty match: A match of length zero: the pattern succeeds at a position without taking any character, as x* does where no x stands. Global searches, splits and replacements report such matches, each engine by its own rules. More in “Repetition with *, +, ? and {m,n}”
abutting a preceding match are ignored, so a* in baac marks the dashes of -b-c-, while Python’s re documentation
says an empty match can occur immediately after a non-empty one, and gives -a-b--d- for sub('x*', '-', 'abxd'). The
split row follows from the APIs: Go’s Split returns the pieces between matches and no more, where JavaScript and Python
add each captured separator, and PHP does the same only with the PREG_SPLIT_DELIM_CAPTURE flag.
Change the subject of the first row
In the JavaScript run, edit 'ab\n' to 'ab' in the first line and run again: the row says true, because the
newline was the only thing between b and the end. Then try 'ab\n' with the m flag to see what multiline mode
does to the same row.
Checkpoint
Check: Rows of the matrix that break a port
Refused or quietly different: the two ways a port fails
A port fails in one of two ways, and they cost very different amounts. In the first, the new engine refuses the pattern
when it builds it, so the failure arrives before any text is read. In the second, the pattern compiles, runs and
answers, and the answer is wrong. Go shows the first kind clearly. This program tries ten constructs that other engines
accept and prints what regexp.Compile returns:
// Patterns that compile in JavaScript, Python or PCRE2 and that Go's regexp refuses, with the exact error text.
// Compile reports a refusal when the pattern is built, so a port that fails this way fails before any text is read.
package main
import (
"fmt"
"regexp"
)
func main() {
patterns := []struct{ construct, pattern string }{
{"lookahead", `a(?=b)`},
{"negative lookahead", `a(?!b)`},
{"lookbehind", `(?<=a)b`},
{"backreference", `(a)\1`},
{"named backreference", `(?P<n>a)(?P=n)`},
{"possessive quantifier", `a++`},
{"atomic group", `(?>a)`},
{"end before final newline", `b\Z`},
{"continue after last match", `\Ga`},
{"count above 1000", `a{1001}`},
}
for _, item := range patterns {
_, err := regexp.Compile(item.pattern)
if err != nil {
fmt.Printf("%-26s %s\n", item.construct, err)
} else {
fmt.Printf("%-26s compiles\n", item.construct)
}
}
} Output
lookahead error parsing regexp: invalid or unsupported Perl syntax: `(?=`
negative lookahead error parsing regexp: invalid or unsupported Perl syntax: `(?!`
lookbehind error parsing regexp: invalid named capture: `(?<=a)b`
backreference error parsing regexp: invalid escape sequence: `\1`
named backreference error parsing regexp: invalid or unsupported Perl syntax: `(?P`
possessive quantifier error parsing regexp: invalid nested repetition operator: `++`
atomic group error parsing regexp: invalid or unsupported Perl syntax: `(?>`
end before final newline error parsing regexp: invalid escape sequence: `\Z`
continue after last match error parsing regexp: invalid escape sequence: `\G`
count above 1000 error parsing regexp: invalid repeat count: `{1001}`
Recorded with Go 1.27.1 on macOS 26 arm64. To run it yourself: go run refusals.go
Lookahead, lookbehind, backreferences, possessive quantifiers and atomic groups are outside the RE2 syntax, which marks
them as not supported, and \Z and \G are not in it either. Two messages deserve a second look. Go reads (?< as the
start of a named group, so a lookbehind is reported as an invalid named capture, and a{1001} is refused for size,
because the counting forms stop at 1000 (the regexp/syntax page says so). Each message names the construct Go tripped
on, not the idea you meant, so read it as a pointer to a row of the matrix.
The second kind is the dangerous one. The next programs ask four engines about patterns that look alike in all of them:
// Patterns that other engines refuse or read differently, asked of JavaScript. None of them throws here.
const show = (label, result) => console.log(`${label.padEnd(26)} ${JSON.stringify(result)}`);
show('\\Ga on "Ga"', 'Ga'.match(/\Ga/g));
show('\\Ga on "aab"', 'aab'.match(/\Ga/g));
show('b\\Z on "abZ"', /b\Z/.test('abZ'));
show('b\\z on "abz"', /b\z/.test('abz'));
show('^a{,2}$ on "aa"', /^a{,2}$/.test('aa'));
show('^a{,2}$ on "a{,2}"', /^a{,2}$/.test('a{,2}'));
show('^a{0,2}$ on "aa"', /^a{0,2}$/.test('aa'));
try {
new RegExp('b\\Z', 'u');
} catch (error) {
console.log(`${'b\\Z with the u flag'.padEnd(26)} ${error.name}`);
} Output
\Ga on "Ga" ["Ga"]
\Ga on "aab" null
b\Z on "abZ" true
b\z on "abz" true
^a{,2}$ on "aa" false
^a{,2}$ on "a{,2}" true
^a{0,2}$ on "aa" true
b\Z with the u flag SyntaxError
Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node silent.mjs
Runs on this device, in your browser. The first run downloads JavaScript (about 0.6 MB), which is kept for the next runs.
Your run, in this browser
Python 3.14.8 · silent/silent.py
"""The same patterns as the JavaScript run, asked of Python's re: refused, or a different meaning."""
import json
import re
def show(label, run):
try:
outcome = json.dumps(run())
except re.PatternError as error:
outcome = f"PatternError: {error}"
print(f"{label:26} {outcome}")
show(r'\Ga on "Ga"', lambda: re.findall(r"\Ga", "Ga"))
show(r'\Ga on "aab"', lambda: re.findall(r"\Ga", "aab"))
show(r'b\Z on "abZ"', lambda: bool(re.search(r"b\Z", "abZ")))
show(r'b\z on "abz"', lambda: bool(re.search(r"b\z", "abz")))
show(r'^a{,2}$ on "aa"', lambda: bool(re.search(r"^a{,2}$", "aa")))
show(r'^a{,2}$ on "a{,2}"', lambda: bool(re.search(r"^a{,2}$", "a{,2}")))
show(r'^a{0,2}$ on "aa"', lambda: bool(re.search(r"^a{0,2}$", "aa"))) Output
\Ga on "Ga" PatternError: bad escape \G at position 0
\Ga on "aab" PatternError: bad escape \G at position 0
b\Z on "abZ" false
b\z on "abz" false
^a{,2}$ on "aa" true
^a{,2}$ on "a{,2}" false
^a{0,2}$ on "aa" true
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 silent.py
Go 1.27.1 · silent/silent.go
// The same patterns as the JavaScript run, asked of Go's regexp: refused, or a different meaning.
package main
import (
"encoding/json"
"fmt"
"regexp"
)
func show(label, pattern string, run func(re *regexp.Regexp) any) {
re, err := regexp.Compile(pattern)
if err != nil {
fmt.Printf("%-26s %s\n", label, err)
return
}
text, _ := json.Marshal(run(re))
fmt.Printf("%-26s %s\n", label, text)
}
func main() {
find := func(subject string) func(re *regexp.Regexp) any {
return func(re *regexp.Regexp) any { return re.FindAllString(subject, -1) }
}
test := func(subject string) func(re *regexp.Regexp) any {
return func(re *regexp.Regexp) any { return re.MatchString(subject) }
}
show(`\Ga on "Ga"`, `\Ga`, find("Ga"))
show(`\Ga on "aab"`, `\Ga`, find("aab"))
show(`b\Z on "abZ"`, `b\Z`, test("abZ"))
show(`b\z on "abz"`, `b\z`, test("abz"))
show(`^a{,2}$ on "aa"`, `^a{,2}$`, test("aa"))
show(`^a{,2}$ on "a{,2}"`, `^a{,2}$`, test("a{,2}"))
show(`^a{0,2}$ on "aa"`, `^a{0,2}$`, test("aa"))
} Output
\Ga on "Ga" error parsing regexp: invalid escape sequence: `\G`
\Ga on "aab" error parsing regexp: invalid escape sequence: `\G`
b\Z on "abZ" error parsing regexp: invalid escape sequence: `\Z`
b\z on "abz" false
^a{,2}$ on "aa" false
^a{,2}$ on "a{,2}" true
^a{0,2}$ on "aa" true
Recorded with Go 1.27.1 on macOS 26 arm64. To run it yourself: go run silent.go
PHP 8.5.11 · silent/silent.php
<?php
// The same patterns as the JavaScript run, asked of PHP's preg functions (PCRE2).
function show(string $label, mixed $result): void
{
printf("%-26s %s\n", $label, json_encode($result));
}
show('\Ga on "Ga"', preg_match_all('/\Ga/', 'Ga', $found) ? $found[0] : []);
show('\Ga on "aab"', preg_match_all('/\Ga/', 'aab', $found) ? $found[0] : []);
show('b\Z on "abZ"', preg_match('/b\Z/', 'abZ') === 1);
show('b\z on "abz"', preg_match('/b\z/', 'abz') === 1);
show('^a{,2}$ on "aa"', preg_match('/^a{,2}$/', 'aa') === 1);
show('^a{,2}$ on "a{,2}"', preg_match('/^a{,2}$/', 'a{,2}') === 1);
show('^a{0,2}$ on "aa"', preg_match('/^a{0,2}$/', 'aa') === 1); Output
\Ga on "Ga" []
\Ga on "aab" ["a","a"]
b\Z on "abZ" false
b\z on "abz" false
^a{,2}$ on "aa" true
^a{,2}$ on "a{,2}" false
^a{0,2}$ on "aa" true
Recorded with PHP 8.5.11 (cli) (NTS) on macOS 26 arm64. To run it yourself: php silent.php
| Pattern on the subject | JavaScript | Python | Go | PHP |
|---|---|---|---|---|
\Ga on Ga |
matches Ga |
refused: bad escape | refused: invalid escape | no match |
b\Z on abZ |
true | false | refused | false |
b\z on abz |
true | false | false | false |
^a{,2}$ on aa |
false | true | false | true |
^a{,2}$ on a{,2} |
true | false | true | false |
JavaScript outside Unicode mode lets a backslash stand before nearly any character and reads the pair as that character
(ECMA-262 Annex B). So \G is a G, \Z is a Z, \z is a z, and \Ga matches the text Ga while b\z matches abz.
With the u flag the same escapes are errors, as the last line of the JavaScript run shows for \Z. Python reserves
unknown escapes of ASCII letters and refuses them, and Go refuses the ones it does not define. In PCRE2, \G is an
assertion that holds “at the first matching position in the subject” (pcre2pattern), which is why PHP finds nothing for
\Ga on Ga and the two a’s in a row on aab.
The braces differ in the same silent way. Python counts {,2} as zero to two, and so does PCRE2 from version 10.43 on
(pcre2pattern: X{,4} is X{0,4}); JavaScript and Go read it as the characters themselves. A pattern that means “at
most two a” in one language means the text a{,2} in the other, and neither says so.
Taking a clean compile for a finished port
Wrong: calling a port done because the new engine built the pattern without an error. Right: a build proves the grammar and nothing else. Every row of the matrix above compiled somewhere and still gave a different answer.
Seen from the other side, the two failure kinds ask for different defences: the engine supplies the first, the porter the second.
Key idea
An engine that refuses a pattern is the cheap failure; the expensive one is a pattern that compiles everywhere and means something different in one place.
Checkpoint
Check: Refused or quietly different
A five-step checklist that finds the rows
The two failure kinds suggest the method. A refusal finds itself, while a quiet difference has to be looked for, and the matrix says where to look. The figure follows one pattern, the named-group swap from the test table of the next section, through all five steps of the checklist:
The drawing shows the whole scene; its 6 steps are listed below.
Every step as text
- The job to move. A JavaScript job turns 7314-05 into 05/7314 with the pattern (?<head>\d{4})-(?<tail>\d{2}) and the template $<tail>/$<head>. The same job has to run in Python, with the same results.
- List what the pattern uses. Read the pattern and the template for everything an engine could read differently: the spelling of a named group, the class \d, and the way the template names a group. Three constructs, written down before anything is changed.
- Look each one up. Find each construct in the matrix of the flavours. Python spells a named group (?P<head>...), where JavaScript writes (?<head>...), its \d holds every Unicode digit, and its templates name a group with \g<tail>. Three rows apply.
- Rewrite pattern and template. Change what the lookup named and nothing else: (?P<head>[0-9]{4})-(?P<tail>[0-9]{2}) and \g<tail>/\g<head>. The pattern now says which digits it accepts, and the template says which group each part is.
- Run the same strings. Put the same case in the table of the differential test and run it in both engines. The copied pattern stops Python with a PatternError; the rewritten one gives 05/7314, as JavaScript does.
- Compare line by line. Read the two outputs side by side. A line that says ok in both engines is done; a line that says DIFF sends the port back to the lookup, because a construct was missed or a row was read wrongly.
Each step has a reason that comes from a mistake already seen in this lesson:
- List every construct, not only the odd-looking ones: the pattern’s syntax and flags, its replacement template, and the call that applies it (replace one or all, split, find). The construct nobody listed is the one nobody looked up.
- Look each one up in the matrix and in the target engine’s documentation. Where the matrix has no row, the documentation decides, and the construct goes into the test table.
- Rewrite the smallest part that differs, one change at a time, so that a failing line points at a single change.
- Run the same strings in both engines, with at least one string for every row the pattern touches.
- Compare the outputs line by line. A line that is ok in both is done; any DIFF sends you back to the lookup, because a construct was missed or a row was read wrongly.
Where a spelling exists that means the same in every engine, choose it, because then nothing needs porting. These do:
| If the pattern says | Write | Why it travels |
|---|---|---|
\d |
[0-9] |
the ten ASCII digits in every engine, where \d is wider in Python |
\w |
[A-Za-z0-9_] |
the same 63 characters everywhere, where \w is wider in Python |
a{,2} |
a{0,2} |
a count in every engine, where {,2} is text in two of them |
| a group used only to group | (?:...) |
nothing is captured, so splits and group numbers stay the same |
(?<y>...) or (?P<y>...) |
a numbered group | neither spelling is accepted by both JavaScript and Python |
$ as the end of the text |
no common spelling | $ in JavaScript, \Z in Python, $ or \z in Go, \z in PCRE2: write one per engine |
The first two rows hold because the shorthand classes are the ones whose scope differs most; the shorthand classes lesson counts the characters. The last row is the honest one: some rows have no portable spelling, and the checklist exists for them.
Checkpoint
Check: The five-step checklist
Proving a port with a differential test
Reading the rows lowers the risk and running the strings settles it. A differential test: A test that runs the same cases through two implementations, such as two regex engines, and reports every case where their results differ. Each case holds a pattern, a subject and the result the job should give. feeds the same cases to two implementations and reports each case where they differ. The cases here are six jobs, each a pattern, a subject and the result the job is meant to give, and every engine prints one line per case in one layout. JavaScript runs the original patterns, so its two columns hold the same pattern:
// A differential test: six cases, each a pattern, a subject and the result the pattern is meant to give.
// Every engine runs the same table and prints one line per case in the same layout, so the outputs can be compared.
// Here JavaScript runs the original patterns, so "copied" and "ported" are the same pattern.
const cases = [
{ name: 'swap-groups', op: 'replace', subject: 'ab@cd', want: 'cd_ab', pattern: '(\\w+)@(\\w+)', template: '$2_$1' },
{ name: 'word-scope', op: 'test', subject: 'café', want: 'false', pattern: '^\\w+$' },
{ name: 'final-newline', op: 'test', subject: '42\n', want: 'false', pattern: '^\\d+$' },
{ name: 'named-groups', op: 'replace', subject: '7314-05', want: '05/7314', pattern: '(?<head>\\d{4})-(?<tail>\\d{2})', template: '$<tail>/$<head>' },
{ name: 'keep-separators', op: 'split', subject: 'a,b;c', want: 'a|,|b|;|c', pattern: '([,;])' },
{ name: 'after-prefix', op: 'find', subject: 'Rs 40, Rs 250 and 7', want: '40|250', pattern: '(?<=Rs )\\d+' },
];
const engine = {
replace: (c) => c.subject.replace(new RegExp(c.pattern, 'g'), c.template),
test: (c) => String(new RegExp(c.pattern).test(c.subject)),
split: (c) => c.subject.split(new RegExp(c.pattern)).join('|'),
find: (c) => [...c.subject.matchAll(new RegExp(c.pattern, 'g'))].map((m) => m[1] ?? m[0]).join('|'),
};
function attempt(c) {
try {
const got = engine[c.op](c);
return { ok: got === c.want, got };
} catch (error) {
return { ok: false, got: `error ${error.name}` };
}
}
console.log('case copied ported');
const wrong = [];
cases.forEach((c, i) => {
const copied = attempt(c);
const ported = attempt(c);
console.log(`${i + 1} ${c.name.padEnd(18)} ${(copied.ok ? 'ok' : 'DIFF').padEnd(7)} ${ported.ok ? 'ok' : 'DIFF'}`);
if (!copied.ok) wrong.push(`${i + 1} ${c.name.padEnd(18)} got ${JSON.stringify(copied.got)}, want ${JSON.stringify(c.want)}`);
});
console.log(wrong.length ? `copied patterns that differ:\n${wrong.join('\n')}` : 'copied patterns that differ: none'); Output
case copied ported 1 swap-groups ok ok 2 word-scope ok ok 3 final-newline ok ok 4 named-groups ok ok 5 keep-separators ok ok 6 after-prefix ok ok copied patterns that differ: none
Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node port_check.mjs
Runs on this device, in your browser. The first run downloads JavaScript (about 0.6 MB), which is kept for the next runs.
Your run, in this browser
Python and Go run the same table twice, first with the pattern and template copied from JavaScript and then with the rewrite. A DIFF in the first column is a port that would have shipped broken; the second column says whether the rewrite repaired it:
Python 3.14.8 · harness/port_check.py
"""A differential test: the same six cases as the JavaScript run, run against Python's re.
Each case is a pattern, a subject and the result it is meant to give. "copied" is the JavaScript pattern and template
as they stand; "ported" is the rewrite. One line per case, in the same layout in every engine."""
import re
cases = [
# name, op, subject, want, (copied pattern, template), (ported pattern, template)
("swap-groups", "replace", "ab@cd", "cd_ab", (r"(\w+)@(\w+)", "$2_$1"), (r"(\w+)@(\w+)", r"\2_\1")),
("word-scope", "test", "café", "false", (r"^\w+$", None), (r"^[A-Za-z0-9_]+$", None)),
("final-newline", "test", "42\n", "false", (r"^\d+$", None), (r"^\d+\Z", None)),
("named-groups", "replace", "7314-05", "05/7314",
(r"(?<head>\d{4})-(?<tail>\d{2})", "$<tail>/$<head>"), (r"(?P<head>\d{4})-(?P<tail>\d{2})", r"\g<tail>/\g<head>")),
("keep-separators", "split", "a,b;c", "a|,|b|;|c", (r"([,;])", None), (r"([,;])", None)),
("after-prefix", "find", "Rs 40, Rs 250 and 7", "40|250", (r"(?<=Rs )\d+", None), (r"(?<=Rs )\d+", None)),
]
ops = {
"replace": lambda p, t, s: re.sub(p, t, s),
"test": lambda p, t, s: str(bool(re.search(p, s))).lower(),
"split": lambda p, t, s: "|".join(re.split(p, s)),
"find": lambda p, t, s: "|".join(re.findall(p, s)),
}
def attempt(op, version, subject, want):
pattern, template = version
try:
got = ops[op](pattern, template, subject)
except re.PatternError:
return False, "error PatternError"
return got == want, got
print("case copied ported")
wrong = []
for number, (name, op, subject, want, copied, ported) in enumerate(cases, 1):
first = attempt(op, copied, subject, want)
second = attempt(op, ported, subject, want)
print(f"{number} {name:18} {'ok' if first[0] else 'DIFF':7} {'ok' if second[0] else 'DIFF'}")
if not first[0]:
wrong.append(f"{number} {name:18} got {first[1]!r}, want {want!r}")
print("copied patterns that differ:" + ("\n" + "\n".join(wrong) if wrong else " none")) Output
case copied ported 1 swap-groups DIFF ok 2 word-scope DIFF ok 3 final-newline DIFF ok 4 named-groups DIFF ok 5 keep-separators ok ok 6 after-prefix ok ok copied patterns that differ: 1 swap-groups got '$2_$1', want 'cd_ab' 2 word-scope got 'true', want 'false' 3 final-newline got 'true', want 'false' 4 named-groups got 'error PatternError', want '05/7314'
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 port_check.py
Go 1.27.1 · harness/port_check.go
// A differential test: the same six cases as the JavaScript run, run against Go's regexp.
// "copied" is the JavaScript pattern and template as they stand; "ported" is the rewrite. One line per case,
// in the same layout in every engine.
package main
import (
"fmt"
"regexp"
"strings"
)
type version struct{ pattern, template, op string }
type testCase struct {
name, subject, want string
copied, ported version
}
// splitKeep is the port of JavaScript's split with a captured separator: Go's Split drops the separators.
func splitKeep(re *regexp.Regexp, subject string) string {
var parts []string
last := 0
for _, at := range re.FindAllStringIndex(subject, -1) {
parts = append(parts, subject[last:at[0]], subject[at[0]:at[1]])
last = at[1]
}
return strings.Join(append(parts, subject[last:]), "|")
}
func run(v version, subject string) (got string, failed bool) {
re, err := regexp.Compile(v.pattern)
if err != nil {
return "error compile", true
}
switch v.op {
case "replace":
return re.ReplaceAllString(subject, v.template), false
case "test":
return fmt.Sprint(re.MatchString(subject)), false
case "split":
return strings.Join(re.Split(subject, -1), "|"), false
case "split-keep":
return splitKeep(re, subject), false
default: // find: group 1 when the pattern has a group, else the whole match
var found []string
for _, m := range re.FindAllStringSubmatch(subject, -1) {
if len(m) > 1 {
found = append(found, m[1])
} else {
found = append(found, m[0])
}
}
return strings.Join(found, "|"), false
}
}
func main() {
cases := []testCase{
{"swap-groups", "ab@cd", "cd_ab", version{`(\w+)@(\w+)`, "$2_$1", "replace"}, version{`(\w+)@(\w+)`, "${2}_${1}", "replace"}},
{"word-scope", "café", "false", version{`^\w+$`, "", "test"}, version{`^\w+$`, "", "test"}},
{"final-newline", "42\n", "false", version{`^\d+$`, "", "test"}, version{`^\d+$`, "", "test"}},
{"named-groups", "7314-05", "05/7314", version{`(?<head>\d{4})-(?<tail>\d{2})`, "$<tail>/$<head>", "replace"}, version{`(?<head>\d{4})-(?<tail>\d{2})`, "${tail}/${head}", "replace"}},
{"keep-separators", "a,b;c", "a|,|b|;|c", version{`([,;])`, "", "split"}, version{`([,;])`, "", "split-keep"}},
{"after-prefix", "Rs 40, Rs 250 and 7", "40|250", version{`(?<=Rs )\d+`, "", "find"}, version{`Rs (\d+)`, "", "find"}},
}
fmt.Println("case copied ported")
var wrong []string
for i, c := range cases {
first, firstFailed := run(c.copied, c.subject)
second, _ := run(c.ported, c.subject)
mark := func(got string) string {
if got == c.want {
return "ok"
}
return "DIFF"
}
fmt.Printf("%d %-18s %-7s %s\n", i+1, c.name, mark(first), mark(second))
if first != c.want || firstFailed {
wrong = append(wrong, fmt.Sprintf("%d %-18s got %q, want %q", i+1, c.name, first, c.want))
}
}
if len(wrong) == 0 {
fmt.Println("copied patterns that differ: none")
return
}
fmt.Println("copied patterns that differ:\n" + strings.Join(wrong, "\n"))
} Output
case copied ported 1 swap-groups DIFF ok 2 word-scope ok ok 3 final-newline ok ok 4 named-groups DIFF ok 5 keep-separators DIFF ok 6 after-prefix DIFF ok copied patterns that differ: 1 swap-groups got "ab", want "cd_ab" 4 named-groups got "$<tail>/$<head>", want "05/7314" 5 keep-separators got "a|b|c", want "a|,|b|;|c" 6 after-prefix got "error compile", want "40|250"
Recorded with Go 1.27.1 on macOS 26 arm64. To run it yourself: go run port_check.go
Python’s copies differ in four of the six cases and so do Go’s, and they are not the same four. Python’s copy breaks on
the template syntax, the wider \w of its café case, the dollar sign before a final newline and the named-group
spelling. Go’s breaks on the template, on the dropped separators of split and on the lookbehind it cannot build, and
its named-group case fails only in the template, since Go accepts both spellings of the pattern. Two cases are already
right in a plain copy in Python and two in Go, which is the reason to run them instead of assuming. After the rewrite every line
is ok.
A table sees only the strings in it. The word-scope case fails in Python only because its subject is café; with an
ASCII subject the copy would have succeeded. So build the table from the matrix: for every row a pattern touches, add a
string that lands on the difference, such as a final newline for the dollar sign, a non-ASCII letter for \w, a run of
the pattern’s own character for an empty match, and a separator for a split.
Write the expected result from the job
Take the expected value from what the job should produce, not from the first engine’s output. A table that copies its answers from the engine you are leaving locks that engine’s quirks into the new one.
Kept beside the code, the table also records each decision: a case stays as the evidence for why a pattern is written as it is.
Key idea
A port is proved by output, not by reading: one table of cases, run in both engines, with a string aimed at every row the pattern touches.
Checkpoint
Check: Proving a port with a test table
When is a port the wrong move?
A port is one of three ways to run a job in a second language. The others are to run one engine on both sides, such as a PCRE2 binding where a language has one, and to do the job without a regex. Answer the three questions below to see which way your case rules out; the cells use the numbers of the test table above.
Answer the questions above: the table then marks what fits your case and says why the rest does not.
| Way | One source of truth | Silent differences | Work to start |
|---|---|---|---|
| Port the pattern Means: a rewrite per engine, kept equal by a test table | Poor two spellings of one idea, held equal only by the test table | Mixed the table caught 4 of 6 copies wrong in Python and 4 in Go; it only sees the strings in it | Good a checklist and a table, with no new dependency |
| Use one engine in both Means: the same library behind both services | Good one pattern string, read by the same syntax on both sides | Mixed same library, still different versions: PHP 8.5.11 bundles PCRE2 10.44, the tester runs 10.49 | Poor a library to add and update in each language, with its own build |
| Do the job without a regex Means: string methods or a parser for the format | Good no pattern to keep in step: a method or a parser does the job | Good a parser refuses text it cannot read instead of matching something near it | Mixed more code than one pattern, written twice |
How the answers decide
- “Does the job have to run in two languages for good, or only while you move from one to the other?” Only during a move: rules out Use one engine in both. A library added to both services for a move that ends is a cost that outlives it; port once, prove it with the table, and drop the old pattern.
- “Does the pattern use lookaround, backreferences or possessive quantifiers?” Yes, at least one: adds a caveat to Port the pattern. Go refuses lookahead, lookbehind, backreferences and possessive quantifiers when the pattern is built, so a port there changes how the job is done, not only how it is spelled.
- “Is the text a structured format with a parser, such as JSON, a URL or a date?” Yes, a known format: adds a caveat to Port the pattern and Use one engine in both. A parser for the format refuses what it cannot read, where a pattern quietly accepts something close to it; a port keeps that weakness in both languages.
- “Does the job have to run in two languages for good, or only while you move from one to the other?” For good, in both and “Is the text a structured format with a parser, such as JSON, a URL or a date?” No, unstructured text: rules out Do the job without a regex. Unstructured text has no parser to call, and string methods for it grow into a second pattern language of their own.
In short: port when a plain pattern has to live in two languages for good, reach for a parser when the text is a known format, and run the test table whichever way you go, because it also tests the replacement.
The five lessons that follow each take one language’s regex API in depth: JavaScript regex techniques for text jobs in the browser and Node.js, Python regex techniques for the re module in code, Regex in PCRE2, PHP and Perl for the preg functions and PCRE2’s own options, Regex in Go, RE2 and re2js for linear-time matching and what it gives up, and Regex in Java, .NET, Ruby and Rust for the other flavours.
Practise: port five Go jobs
The exercise hands you a Go service’s five text jobs, copied as Go runs them, and asks for the same results from JavaScript or Python on the public strings. The sample tests run in your browser; each failure prints the string, what your language gave and what Go gave.
Exercise · Medium · JavaScript, Python
Port five Go patterns to your language
A service written in Go does five text jobs with its regexp package. Its patterns and replacement templates are below, in Go's syntax. Move all five to your language so that each one gives the same result as Go on the public strings of the tests. Open ports.mjs (JavaScript) or ports.py (Python): it holds the five Go jobs copied as they are, and the tests show which of them differ from Go.
Each job is a pattern, some flags and, for a replacement, a template. The tests apply every job to its strings with your language's own replace, test or split call: all matches are replaced, a test looks for a match anywhere, and a split cuts at every match.
namesreplaces every match of(?P<first>\w+) (?P<last>\w+)with the template${last}, ${first}, soAsha RaobecomesRao, Asha.phonetests whether^\d{3}-\d{4}$matches. In Go it is true only for three digits, a hyphen and four digits, with nothing after them.padreplaces every match of(\d+)with${1}0, which writes a zero after each number.dashesreplaces every match ofa*with-.fieldssplits a string at every match of(,|;).
Go's results are fixed by the tests, so a copy that differs is a port that is not done yet. Change a job's pattern, flags or template, never the tests. Keep each job to one pattern and one template: no loops and no extra code.
JavaScript · Starter code · ports.mjs
// The Go service's five jobs, copied as Go runs them (RE2 syntax, Go replacement templates).
// Rewrite each job so that JavaScript gives the same results as Go on the strings of the tests.
// A job has a pattern, flags (a string such as 'i', possibly empty) and, for a replacement, a template.
export const ports = {
names: { pattern: '(?P<first>\\w+) (?P<last>\\w+)', flags: '', template: '${last}, ${first}' },
phone: { pattern: '^\\d{3}-\\d{4}$', flags: '' },
pad: { pattern: '(\\d+)', flags: '', template: '${1}0' },
dashes: { pattern: 'a*', flags: '', template: '-' },
fields: { pattern: '(,|;)', flags: '' },
}; The sample tests · ports.test.mjs
import { test, assert } from 'mysmartcopilot:test';
import { ports } from './ports.mjs';
// The tests apply a job the way JavaScript applies a pattern: every match for a replacement, any match for a test.
const build = (job, extra = '') => new RegExp(job.pattern, job.flags.replace(/g/g, '') + extra);
const replaceAll = (job, subject) => subject.replace(build(job, 'g'), job.template);
const matches = (job, subject) => build(job).test(subject);
const cut = (job, subject) => subject.split(build(job));
// The expected values are what Go 1.27.1 returned for the original patterns.
test('names: first and last name swap, as Go swaps them', () => {
const cases = [
['Asha Rao', 'Rao, Asha'],
['Ravi Iyer-Nair', 'Iyer, Ravi-Nair'],
['José Kumar', 'José Kumar'],
['Mei', 'Mei'],
];
for (const [subject, want] of cases) {
assert.equal(replaceAll(ports.names, subject), want, `names on ${JSON.stringify(subject)}`);
}
});
test('phone: three digits, a hyphen, four digits, and nothing after', () => {
const cases = [
['555-0199', true],
['555-0199\n', false],
['555-019', false],
['५५५-०१९९', false],
];
for (const [subject, want] of cases) {
assert.equal(matches(ports.phone, subject), want, `phone on ${JSON.stringify(subject)}`);
}
});
test('pad: a zero after every number', () => {
const cases = [
['Batch 7 of 12', 'Batch 70 of 120'],
['no digits', 'no digits'],
['100', '1000'],
];
for (const [subject, want] of cases) {
assert.equal(replaceAll(ports.pad, subject), want, `pad on ${JSON.stringify(subject)}`);
}
});
test('dashes: empty matches follow Go, none right after a match', () => {
const cases = [
['baac', '-b-c-'],
['xyz', '-x-y-z-'],
['aaa', '-'],
['', '-'],
];
for (const [subject, want] of cases) {
assert.equal(replaceAll(ports.dashes, subject), want, `dashes on ${JSON.stringify(subject)}`);
}
});
test('fields: the separators are dropped', () => {
const cases = [
['a,b;c', ['a', 'b', 'c']],
['a,,b', ['a', '', 'b']],
['', ['']],
['a;', ['a', '']],
];
for (const [subject, want] of cases) {
assert.deepEqual(cut(ports.fields, subject), want, `fields on ${JSON.stringify(subject)}`);
}
}); Python · Starter code · ports.py
"""The Go service's five jobs, copied as Go runs them (RE2 syntax, Go replacement templates).
Rewrite each job so that Python gives the same results as Go on the strings of the tests.
A job has a pattern, flags (0, or flags such as re.ASCII) and, for a replacement, a template."""
import re
PORTS = {
"names": {"pattern": r"(?P<first>\w+) (?P<last>\w+)", "flags": 0, "template": "${last}, ${first}"},
"phone": {"pattern": r"^\d{3}-\d{4}$", "flags": 0},
"pad": {"pattern": r"(\d+)", "flags": 0, "template": "${1}0"},
"dashes": {"pattern": r"a*", "flags": 0, "template": "-"},
"fields": {"pattern": r"(,|;)", "flags": 0},
} The sample tests · test_ports.py
import re
from ports import PORTS
# The tests apply a job the way Python applies a pattern: every match for a replacement, any match for a test.
def replace_all(job, subject):
return re.sub(job["pattern"], job["template"], subject, flags=job["flags"])
def matches(job, subject):
return re.search(job["pattern"], subject, flags=job["flags"]) is not None
def cut(job, subject):
return re.split(job["pattern"], subject, flags=job["flags"])
# The expected values are what Go 1.27.1 returned for the original patterns.
def test_names():
"""names: first and last name swap, as Go swaps them"""
cases = [
("Asha Rao", "Rao, Asha"),
("Ravi Iyer-Nair", "Iyer, Ravi-Nair"),
("José Kumar", "José Kumar"),
("Mei", "Mei"),
]
for subject, want in cases:
assert replace_all(PORTS["names"], subject) == want, f"names on {subject!r}"
def test_phone():
"""phone: three digits, a hyphen, four digits, and nothing after"""
cases = [("555-0199", True), ("555-0199\n", False), ("555-019", False), ("५५५-०१९९", False)]
for subject, want in cases:
assert matches(PORTS["phone"], subject) is want, f"phone on {subject!r}"
def test_pad():
"""pad: a zero after every number"""
cases = [("Batch 7 of 12", "Batch 70 of 120"), ("no digits", "no digits"), ("100", "1000")]
for subject, want in cases:
assert replace_all(PORTS["pad"], subject) == want, f"pad on {subject!r}"
def test_dashes():
"""dashes: empty matches follow Go, none right after a match"""
cases = [("baac", "-b-c-"), ("xyz", "-x-y-z-"), ("aaa", "-"), ("", "-")]
for subject, want in cases:
assert replace_all(PORTS["dashes"], subject) == want, f"dashes on {subject!r}"
def test_fields():
"""fields: the separators are dropped"""
cases = [("a,b;c", ["a", "b", "c"]), ("a,,b", ["a", "", "b"]), ("", [""]), ("a;", ["a", ""])]
for subject, want in cases:
assert cut(PORTS["fields"], subject) == want, f"fields on {subject!r}" A hint
Run the tests first: each failure prints the string, what your language gave and what Go gave. For every failing job, find which of its three parts differs between the languages, the pattern's syntax, what a class such as \w or \d holds, or the way the template names a group, and fix that part only. The dashes job is the hardest: Go never lets an empty match start where the previous match ended, and neither JavaScript nor Python has that rule, so the pattern has to say it.
Results of the sample tests
| Test | Result | Details |
|---|
What your code printed
The sample tests run on this device, in your browser (QuickJS, Pyodide): nothing is sent to mysmartcopilot.com. The first run of each language downloads it: JavaScript (about 0.6 MB) or Python (about 13.5 MB), which is kept for the next runs. A check in your browser is feedback for you, not proof that the code is right for every input.
Then test the six ideas of this lesson, from replacement templates to the decision not to port. The session leads with the one you know least so far, and every pick explains itself at once:
Practice that adapts to you
Practise this lesson
13 questions about 6 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.
Interview questions
Warm-up: why does a regex that works in one language give a different result in another, and how do you port one safely?
Each language implements its own flavour: it chooses the syntax, what the shorthand classes hold, how the anchors treat
a final newline, and how replacement templates and empty matches behave. Some differences are refusals, such as Go
rejecting lookaround, and show at once. Others compile everywhere and mean something else, such as a{,2}, which is a
count in Python and literal text in JavaScript. A safe port lists every construct, looks each one up, rewrites the
smallest part, and then runs the same strings in both engines, with a string aimed at each row touched. Only the output
of that table proves the result.
Two design questions use this method on larger moves, one a migration and one a mystery in production. Decide what you would check first in each, then open it:
Model answers open with any pass: ₹79 for 1 week ($2.99), one payment that never renews.
-
Mid to senior
A browser tool uses a JavaScript pattern with a lookahead, a backreference and the template $<year>-$<month>. The team moves the same job into a Go service. Walk through what you would check before shipping, what cannot be translated, and how you would show that the Go version matches.
-
Mid to senior
A pattern was ported from Python to JavaScript. It compiled, every unit test succeeded, and production data now show different matches. Which kinds of construct could explain it, and how do you find the one responsible?
Flashcards · 7 cards
Recall it, then check
Say the answer to yourself before you look: recalling it is what makes it stick.
-
What does replacing the group of (a) with $1x give on the text ab in JavaScript, PHP and Go?
axb in JavaScript and PHP. Go gives b: it reads $1x as a group named 1x, finds none and writes nothing, without an error. Python treats the dollar form as plain text.
-
How do you write group 1 followed by the letter x in a Go and in a Python replacement?
${1}x in Go, and \g<1>x or \1x in Python. A template is read by the engine you move to, so copy it only after you rewrite it.
-
What are the two ways a ported pattern can fail, and which one costs more?
The new engine can refuse the pattern, which costs one error message, or compile it and quietly mean something else, which costs a wrong answer in production. A clean compile proves only the grammar.
-
Why is a{,2} a quiet trap when a pattern moves between Python and JavaScript?
Both engines compile it, but Python reads it as a count, up to two a's, while JavaScript without a flag reads the same five characters as literal text.
-
Which rows of the flavour matrix break ports most often?
The dollar sign before a final newline, \Z and \z, {,n}, the spelling of named groups, lookbehind limits, replacing empty matches, and split with captured separators.
-
What are the five steps of the porting checklist?
List every construct, look each one up, rewrite the smallest part, run the same strings in both engines, and compare. Every difference sends you back to the look-up.
-
What proves a port, and what are the limits of that proof?
A differential test: one table of pattern, subject and expected result, run in both engines. It proves the port only on the strings it holds, so each row the pattern touches needs a string that lands on its difference.
Key takeaways
- A replacement template is read by the engine you move to:
$1xis group 1 and x in JavaScript and PHP, text in Python, and the group named1xin Go, so the same job is written four ways. - The rows that most often break a port are the dollar sign before a final newline,
\Zand\z,{,n}, the named-group spelling, lookbehind limits, empty-match replacement and split with captured separators. - An engine can refuse a pattern, which costs one error message, or compile it and mean something else, which costs a wrong answer in production; a clean compile proves only the grammar.
- The checklist is list, look up, rewrite, run in both engines and compare, and it sends every DIFF back to the lookup.
- A differential test of pattern, subject and expected result proves a port only on the strings it holds, so each row the pattern touches needs a string that lands on its difference.
Terms in this lesson
- differential test
- A test that runs the same cases through two implementations, such as two regex engines, and reports every case where their results differ. Each case holds a pattern, a subject and the result the job should give.
- flavour matrix
- A table of regex constructs against engines, each cell recording what that engine does with the construct: accept it, refuse it or read it another way. It shows which parts of a pattern need work before a port.
- regex port
- A pattern, replacement template or flag set rewritten for a second regex engine so that it gives the same results as the first on the strings that matter. Copying the text of the pattern is not enough.
References
- ECMAScript 2026 (ECMA-262, 17th edition): GetSubstitution, the replacement template of String.prototype.replace (Ecma International (TC39))
- ECMAScript 2026: Regular Expressions Patterns (Annex B, identity escapes outside Unicode mode) (Ecma International (TC39))
- re: Regular expression syntax (repetition, escapes, lookbehind, \Z and \z) (Python Software Foundation)
- re.sub: replacement escapes, \g<...> and empty matches (Python Software Foundation)
- pcre2pattern: Circumflex and dollar, simple assertions, repetition and lookbehind (PCRE2 Project)
- RE2 syntax (Google)
- regexp: Expand, ReplaceAllString and the Find methods (The Go Authors)
- regexp/syntax: regular expression syntax (The Go Authors)
- PHP Manual: preg_replace (The PHP Group)
- PHP Manual: preg_split (The PHP Group)
- PHP Manual: PCRE (Perl-compatible regular expressions) (The PHP Group)
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress