Your country

Tools that support it use your country for local currency, number formats, units and paper size. Your choice is saved only in this browser.

Type a name or a two-letter code. Use the up and down arrow keys to move through the countries, Enter to choose one and Escape to close.

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.

  • Intermediate
  • 25 minutes
  • Examples run with Node.js 24.21.0, quickjs 0.32.0, Python 3.14.8, Pyodide 314.0.7, Go 1.27.1 and PHP 8.5.11
  • By MySmartCoPilot
Free lesson, Pro answers: 25 minutes
Free: the whole lesson. With a pass: the model answers to its 2 interview questions.

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:

JavaScript: seven replacement templates on ab JavaScript · template-js/template.mjs
// 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

$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, Go and PHP: the same job written as a replacement template

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

  1. Question 1 of 2 In Go, what does regexp.MustCompile("(a)").ReplaceAllString("ab", "$1x") return?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: b

    Go takes the name after the dollar sign as long as possible, so $1x is the variable 1x, which is not a group of the pattern and expands to nothing: the result is b. The template ${1}x ends the name at the closing brace and gives axb.

  2. Question 2 of 2 You need group 1 followed by the digit 0 in a Python replacement. Which template does it?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: \g<1>0

    Python templates use a backslash. A bare \1 followed by a digit is ambiguous, so \10 means group ten; \g<1> closes the number and \g<1>0 is group 1 and then the character 0, which is a0b on the text ab.

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:

JavaScript: nine rows of the matrix JavaScript · rows-js/rows.mjs
// 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

Python, Go, PHP and PCRE2: the same nine rows

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

  1. Question 1 of 2 Which engines of the lesson report a match for b$ on the text ab followed by a line feed? Choose all that do.

    Choose every answer that is right.

    Show the answer: Question 1 of 2

    Answer:

    • PHP
    • Python

    Python and PHP report a match, JavaScript and Go do not. That one row is why a validator with a dollar sign can accept a value plus a line feed in one language and refuse it in another.

  2. Question 2 of 2 Splitting a,b;c on ([,;]) with the call each language offers for it, which engine returns only a, b and c?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: Go

    JavaScript and Python keep what a capturing group of the split pattern matched. Go never does, and PHP does so only with PREG_SPLIT_DELIM_CAPTURE. A group that exists only to group should be written as (?:...).

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:

Go: ten constructs its engine refuses, with the exact messages Go · refusals/refusals.go
// 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:

JavaScript: patterns that other engines refuse or read another way JavaScript · silent-js/silent.mjs
// 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

Python, Go and PHP: the same patterns on the same subjects

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

  1. Question 1 of 2 What does 'Ga'.match(/\Ga/g) return in JavaScript?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: ["Ga"]

    JavaScript has no G assertion. Without the u flag the pair is read as the letter G, so the pattern compiles and matches the text Ga, while Python and Go refuse it and PCRE2 gives it another meaning.

  2. Question 2 of 2 A Python pattern ^[0-9]{,3}$ is copied unchanged into JavaScript with no flags. What happens to the text 12?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: It compiles and rejects 12, because the braces are literal text

    A brace without a first number is a count in Python and in PCRE2 from version 10.43, and plain text in JavaScript and Go. The port compiles and quietly changes the rule; writing {0,3} says the same thing everywhere.

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:

Interactive · step by step The five steps of a port, on one pattern Step through the move of one JavaScript pattern and its template to Python: each step on the left produces the result on its right.
The five steps of a port, on one pattern, step 6 of 6. Showing 1 List them, 2 Look up rows, 3 Rewrite, 4 Run both, 5 Compare, 3 constructs, 3 rows apply, Python version, Same table, ok on all lines. In focus: 5 Compare, ok on all lines.1 List them2 Look up rows3 Rewrite4 Run both5 Compare3 constructsname, \d, template3 rows applyeach differsPython version(?P<, [0-9], \g<Same tabletwo enginesok on all linesor back to 2

The drawing shows the whole scene; its 6 steps are listed below.

Every step as text
  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. 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.
  6. 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

  1. Question 1 of 2 Which order does the porting checklist follow?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: List the constructs, look them up, rewrite, run both engines, compare

    The steps run in order because each one feeds the next. Any DIFF in the last step returns to the lookup, since a construct was missed or a row was misread.

  2. Question 2 of 2 Which spelling means the same in JavaScript, Python, Go and PHP?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: a{0,2}

    Where a spelling exists that all engines read alike, use it: a full count, a class written out, a non-capturing group. The rows without one, such as the named group and the end of the text, are written per engine.

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:

JavaScript: six cases, the original patterns JavaScript · harness-js/port_check.mjs
// 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

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 and Go: six cases, copied and then ported

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

  1. Question 1 of 2 In a harness run, the line 5 keep-separators DIFF ok appears. What does it say?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: The copied pattern gave a different result from the expected one, and the rewrite fixed it

    Read each line as copied, then ported. DIFF and ok is the useful pair: it records a change the port needed and shows that the change was enough.

  2. Question 2 of 2 A Go pattern ^\d{4}$ is ported to PHP unchanged. Which subject exposes a difference?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: 7314 followed by a line feed

    A string exposes a difference only if it lands on the row where the engines disagree. For the dollar sign that is a final newline, which is why the table needs one string aimed at each row the pattern touches.

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.

Interactive · answer and compare How do you get one pattern into a second language? Answer three questions about the job and see which of the three ways is ruled out or comes with a caveat.
Does the job have to run in two languages for good, or only while you move from one to the other?
Does the pattern use lookaround, backreferences or possessive quantifiers?
Is the text a structured format with a parser, such as JSON, a URL or a date?

Answer the questions above: the table then marks what fits your case and says why the rest does not.

Three ways to run one text job in a second language, against what each leaves you to maintain
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.

  • names replaces every match of (?P<first>\w+) (?P<last>\w+) with the template ${last}, ${first}, so Asha Rao becomes Rao, Asha.
  • phone tests whether ^\d{3}-\d{4}$ matches. In Go it is true only for three digits, a hyphen and four digits, with nothing after them.
  • pad replaces every match of (\d+) with ${1}0, which writes a zero after each number.
  • dashes replaces every match of a* with -.
  • fields splits 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.

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.

Regex Tester Paste a pattern and a subject to see what your browser's own engine does. It is one column of the test table, the JavaScript flavour of this lesson.

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.

  1. Question 1 of 13 In JavaScript, what does "ab".replace(/(a)/, "$10") return?

    Choose one answer.

    Show the answer: Question 1 of 13

    Answer: a0b

    ECMA-262 says a two-digit reference above the number of groups is read as a one-digit reference followed by a digit, so $10 with one group is $1 and then 0: a0b.

  2. Question 2 of 13 Which PHP templates give axb for the pattern /(a)/ on ab? Choose all that do.

    Choose every answer that is right.

    Show the answer: Question 2 of 13

    Answer:

    • \1x
    • ${1}x
    • $1x

    PHP accepts three spellings for a numbered group and none for a named one: use preg_replace_callback when the template must name a group.

  3. Question 3 of 13 A JavaScript call uses the template $<year>-$<month> with named groups. Which template does the same in Go?

    Choose one answer.

    Show the answer: Question 3 of 13

    Answer: ${year}-${month}

    The same job is $<name> in JavaScript, \g<name> in Python and ${name} in Go. A template is never copied; it is rewritten for the engine that reads it.

  4. Question 4 of 13 Which engine of the lesson accepts the lookbehind (?<=a+)b?

    Choose one answer.

    Show the answer: Question 4 of 13

    Answer: JavaScript

    JavaScript is the only engine here that takes an unbounded lookbehind. PCRE2 allows variable lengths up to a limit but not an unbounded repeat, Python wants a fixed width, and Go has none.

  5. Question 5 of 13 How many dashes does replacing every match of a* with - produce in Go for the text baac?

    Type a number.

    A hint

    Count the positions where Go lets an empty match start. One of them touches the end of the aa match.

    Show the answer: Question 5 of 13

    Answer: 3

    Go gives -b-c-: an empty match before b, the match aa, and an empty match at the end. The empty match right after aa is ignored because it abuts the preceding match. JavaScript and Python keep it and give four dashes.

  6. Question 6 of 13 Which kind of port failure is the cheaper to find?

    Choose one answer.

    Show the answer: Question 6 of 13

    Answer: A pattern the new engine refuses when it builds it

    A refusal is a build error and a quiet difference is a wrong answer, so the test table exists for the second kind.

  7. Question 7 of 13 The pattern ^a{,2}$ is tested on the text aa. Which result list is right?

    Choose one answer.

    Show the answer: Question 7 of 13

    Answer: true in Python and PHP, false in JavaScript and Go

    The run records true for Python and PHP and false for JavaScript and Go on aa, and the opposite on the literal text a{,2}. No engine reports an error.

  8. Question 8 of 13 Why does the checklist list every construct before anything is rewritten?

    Choose one answer.

    Show the answer: Question 8 of 13

    Answer: A construct that was never listed is one nobody looked up

    The list turns an unexamined pattern into a set of rows to check. The construct left off it is the one that differs without anyone noticing.

  9. Question 9 of 13 A JavaScript pattern ends with $ and must reject a value followed by a line feed. Which ending does that in Python?

    Choose one answer.

    Show the answer: Question 9 of 13

    Answer: \Z

    The end of the text has no spelling shared by all engines: $ in JavaScript, \Z in Python, $ or \z in Go and \z in PCRE2. The checklist sends such a row to be written once per engine.

  10. Question 10 of 13 In the lesson's harness run, how many of the six copied cases differed from the expected result in Go?

    Type a number.

    A hint

    Count the DIFF marks in the first column of the Go run.

    Show the answer: Question 10 of 13

    Answer: 4

    Go differs on swap-groups, named-groups, keep-separators and after-prefix, four cases, and agrees on word-scope and final-newline. Python also differs in four, but not the same four.

  11. Question 11 of 13 Why does the word-scope case use the subject café and not cafe?

    Choose one answer.

    Show the answer: Question 11 of 13

    Answer: With only ASCII letters, a copy of the pattern would give the same result in both engines

    A test string reveals a difference only if it lands on the row where engines differ. The accented letter lands on the word-class row.

  12. Question 12 of 13 A pattern uses a lookahead and must also run in a Go service. What follows from that?

    Choose one answer.

    Show the answer: Question 12 of 13

    Answer: The port changes how the job is done, because Go refuses lookahead when the pattern is built

    Some rows have no counterpart. When the target engine lacks the construct, the rewrite is a redesign, and the test table is how it is proved.

  13. Question 13 of 13 Which text is better handled by a parser than by a ported pattern?

    Choose one answer.

    Show the answer: Question 13 of 13

    Answer: A JSON document

    Where a known format has a parser, the parser both reads it and refuses what is not that format, and there is no pattern to keep in step between two languages.

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.

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

    Model answer, Pro. Model answers open with any pass; prices are in the notice on this page. First step and follow-ups

  2. 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?

    Model answer, Pro. First step and follow-ups

Flashcards · 7 cards

Recall it, then check

Say the answer to yourself before you look: recalling it is what makes it stick.

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

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

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

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

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

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

  7. 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: $1x is group 1 and x in JavaScript and PHP, text in Python, and the group named 1x in 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, \Z and \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

Related tools

Report a problem with this lesson

Quick answers and tool search

Type to search tools or to get a quick answer, for example 18% of 2500. Use the up and down arrow keys to move through the results, Enter to choose, and Escape to close.