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

Regex in PCRE2, PHP and Perl

How pcre2grep and pcre2test, PHP preg functions and Perl share one regex dialect but differ in delimiters, modifiers, errors and replacements.

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

What you will learn

  • Compare how pcre2test, PHP and Perl write a pattern, set its options and build a replacement
  • Write a PHP pattern string with a delimiter and modifiers that suit the text, u included
  • Diagnose a false from a preg function by telling it from 0 and reading the warning or the run error
  • Rewrite a replacement that changes case or names a group for PCRE2, PHP and Perl
  • Explain what a match limit stops and measure it with pcre2test

Before you start

What you’ll learn · Before you start

What you will learn

  • Compare how pcre2test, PHP and Perl write a pattern, set its options and build a replacement
  • Write a PHP pattern string with a delimiter and modifiers that suit the text, u included
  • Diagnose a false from a preg function by telling it from 0 and reading the warning or the run error
  • Rewrite a replacement that changes case or names a group for PCRE2, PHP and Perl
  • Explain what a match limit stops and measure it with pcre2test

Before you start

On this page

PHP 8.5.11 runs its patterns through PCRE2 10.44, the pcre2test and pcre2grep programs on the same Mac are PCRE2 10.49, and Perl runs an engine of its own that PCRE2 imitates. Three front doors lead to nearly one 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”, and each has its own way to write a pattern, to set its options and to build a replacement: a missing modifier makes \d skip the Devanagari digit ७, and one stray character turns a call into false.

This lesson goes through the doors in turn: PHP delimiters and modifiers, how to read a failed preg_* call, the replacements that only some doors can build, and the two shell programs that show what the library is doing. By the end you can write a PHP pattern with the right delimiter and letters, diagnose a false, rewrite a replacement for each door, and read a match limit.

One dialect, three front doors

The previous lesson compared flavours across many languages; this one stays in one family and looks at its doors. Start with what is installed. The run below prints the version of PHP and the version of the PCRE2 library inside it:

PHP: the PCRE2 version behind the preg functions PHP · php-version/version.php
<?php
// Two version numbers: the PHP that runs the script and the PCRE2 library inside it.
// PCRE_VERSION also carries a release date after the number; only the number is printed.
echo "PHP_VERSION  ", PHP_VERSION, "\n";
echo "PCRE2 library ", explode(' ', PCRE_VERSION)[0], "\n";

Output

PHP_VERSION  8.5.11
PCRE2 library 10.44

Recorded with PHP 8.5.11 (cli) (NTS) on macOS 26 arm64. To run it yourself: php version.php

The library inside PHP 8.5.11 is 10.44, and the first line of every pcre2test transcript later in this lesson says 10.49. They are two releases of one library, so a feature added in between exists at one door and not at the other. The PHP manual calls the dialect compatible with Perl’s, and the pcre2pattern manual says PCRE2 tries to match Perl’s syntax and semantics as closely as it can. Perl itself is the odd door out: it keeps its own engine, so a pattern that works there is a close relative of one that works in PHP, not the same program.

The figure follows a pattern through the four stages PCRE2 gives every user and marks where each door takes hold of it. Step through it before reading the table:

Interactive · step by step A pattern's four stages and the three front doors Step through what the library does with a pattern, then see where pcre2test, PHP and Perl each take hold of it.
A pattern's four stages and the three front doors, step 5 of 5. Showing Pattern text, Compile, Match, Substitute, pcre2test, PHP preg_*, Perl s///. In focus: Pattern text, Compile, Substitute.Pattern textwhat you writeCompileoptions fixed hereMatchlimits apply hereSubstitutereplacement builtpcre2testand pcre2grepPHP preg_*PCRE2 10.44Perl s///its own engine

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

Every step as text
  1. Four stages. PCRE2 turns the pattern text into a compiled form once, runs it against a subject, and can then build a replacement from the match. Every tool and language below reaches these stages in its own way.
  2. The shell tools. pcre2test reads a line such as /a+b/i, so the modifiers after the second slash are compile options. Modifiers after \= at the end of a subject line apply only to that match: find_limits and match_limit live there.
  3. PHP. A PHP pattern is a string whose first character is the delimiter; the letters after the closing delimiter, such as i or u, are the compile options. PHP 8.5.11 carries PCRE2 10.44, older than the 10.49 of pcre2test here.
  4. Perl. Perl does not call the PCRE2 library: its engine is its own, and PCRE2 imitates its syntax. A Perl replacement is an interpolated string, so \u, \U and \E change case there while the substitution is built.
  5. Where they part. The pattern language is shared; the surroundings are not. A delimiter, a modifier letter and the rules of a replacement string belong to the front door, which is why a pattern copied between doors needs a second look.

Door The pattern is written as Options go The replacement is
pcre2test a line /pattern/modifiers after the closing slash; per subject after \= replace= text, read fully with substitute_extended
PHP a string '/pattern/modifiers' letters after the closing delimiter a string of \n, $n and ${n}, or a callback
Perl s/pattern/replacement/flags in code flags after the last delimiter a string interpolated like a double-quoted one

Key idea

The pattern language is shared, but the frame around it is not: where options go and what a replacement may contain belong to the door you came in by.

Checkpoint

Check: Three front doors to one dialect

  1. One question A PHP script calls preg_match. Which regex engine runs the pattern?

    Choose one answer.

    Show the answer: One question

    Answer: The PCRE2 library inside PHP, version 10.44 in the version run

    The preg functions call PCRE2. The copy inside PHP 8.5.11 is 10.44, while the pcre2test and pcre2grep programs of the later runs are 10.49, so the dialect is shared and the build may not be.

A PHP pattern is a string: delimiter first, modifiers last

A PHP pattern is an ordinary string, not a literal the language parses, so PHP finds the pattern’s edges from its first character. That character is the pattern delimiter: The character that opens and closes a pattern written as a string, as in PHP's preg functions. It is the string's first character and must end the body; modifier letters follow the closing one.: the manual allows any single-byte character that is not alphanumeric, not a backslash, not NUL and not white space, and the same character must end the body. The bracket pairs (), {}, [] and <> work too, with the closing bracket ending the body. Three spellings of one path test follow, and a fourth string whose first character was meant as an anchor:

PHP: three delimiters, and a ^ that became one PHP · php-delimiters/delimiters.php
<?php
// The first character of a PHP pattern string is its delimiter. Three spellings of the same path test,
// and one string whose first character was meant as an anchor.
$path = '/usr/local/share';
$patterns = [
    '/^\/usr\/local\//',   // slash delimiter: every slash in the body is escaped
    '#^/usr/local/#',      // hash delimiter: nothing to escape
    '{^/usr/local/}',      // bracket delimiters: the pair { }
    '^/usr/local/',        // no delimiter: PHP takes ^ as one and looks for a second ^
];
foreach ($patterns as $pattern) {
    $result = @preg_match($pattern, $path);
    printf("%-18s %s\n", $pattern, $result === false ? 'false: ' . error_get_last()['message'] : "returned $result");
}

Output

/^\/usr\/local\//  returned 1
#^/usr/local/#     returned 1
{^/usr/local/}     returned 1
^/usr/local/       false: preg_match(): No ending delimiter '^' found

Recorded with PHP 8.5.11 (cli) (NTS) on macOS 26 arm64. To run it yourself: php delimiters.php

The slash version needs a backslash before every slash of the path; the hash and the braces need none. The last string has no closing ^, so PHP warns that it found no ending delimiter and the call returns false. Which delimiter to pick is a readability question only, since none of them changes what the body matches. Read a whole pattern from left to right in the next figure:

Interactive · stage by stage Reading a PHP pattern string from left to right Step through the string #^[1-9]\d{5}$#u one part at a time and see what PHP takes each part to be.
Reading a PHP pattern string from left to right, step 4 of 4. Showing Opening delimiter (# is the delimiter), Pattern body (^[1-9]\d{5}$), Closing delimiter (the second #), Modifier letters (u: UTF-8). In focus: Modifier letters.Opening delimiter# is the delimiterPattern body^[1-9]\d{5}$Closing delimiterthe second #Modifier lettersu: UTF-8

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

Every step as text
  1. The first character. In #^[1-9]\d{5}$#u the first character is #. PHP does not match it: it takes it as the delimiter and looks for the same character again further on. Any single-byte character that is not a letter, a digit, a backslash or white space can serve.
  2. The body. Between the delimiters, ^[1-9]\d{5}$ is a PCRE2 pattern: a first digit from 1 to 9, then five digits, from the start of the text to its end. PHP hands it over unchanged.
  3. The closing delimiter. The second # ends the body. A # inside the body would have to be written \# to be matched as text, which is why a pattern full of slashes is easier to read with a different delimiter.
  4. The modifier letters. Everything after the closing delimiter is a modifier. Here u means UTF-8: the pattern and the text are read as code points, and \d follows Unicode. A letter PHP does not know is a warning and a false result.

Everything after the closing delimiter is a pattern modifier: A letter written after the closing delimiter of a pattern that sets an option for the whole pattern, such as i for case-insensitive matching, m for line anchors or u for UTF-8 and Unicode classes., one letter each. The eight that change results most often are in the table, and the run after it tries seven of them with and without the letter (u has a run of its own further down):

Letter What it changes
i letters match both cases
m ^ and $ also match at every line break
s the dot matches a line break too
x white space and # comments in the pattern are ignored
u pattern and text are UTF-8, and \d, \w and \s follow Unicode
U greedy and lazy swap meaning
A the match must start at the first position
D $ matches only at the very end, not before a final newline

S is still accepted but has done nothing since PHP 7.3, and n (PHP 8.2) makes plain groups non-capturing.

PHP: the same pattern with and without one modifier letter PHP · php-modifiers/modifiers.php
<?php
// One modifier at a time: the same subject with and without the letter after the closing delimiter.
$rows = [
    ['i', '/error/',       '/error/i',       'ERROR: disk full'],
    ['m', '/^b$/',         '/^b$/m',         "a\nb\nc"],
    ['s', '/a.b/',         '/a.b/s',         "a\nb"],
    ['x', '/\d{3} \d{3}/', '/\d{3} \d{3}/x', '411001'],
    ['U', '/<.+>/',        '/<.+>/U',        '<b>bold</b>'],
    ['A', '/b/',           '/b/A',           'ab'],
    ['D', '/^a$/',         '/^a$/D',         "a\n"],
];
foreach ($rows as [$letter, $without, $with, $subject]) {
    echo $letter, ' on ', json_encode($subject, JSON_UNESCAPED_SLASHES), "\n";
    foreach ([$without, $with] as $pattern) {
        $found = preg_match($pattern, $subject, $m) ? json_encode($m[0], JSON_UNESCAPED_SLASHES) : 'no match';
        printf("    %-16s %s\n", $pattern, $found);
    }
}

Output

i on "ERROR: disk full"
    /error/          no match
    /error/i         "ERROR"
m on "a\nb\nc"
    /^b$/            no match
    /^b$/m           "b"
s on "a\nb"
    /a.b/            no match
    /a.b/s           "a\nb"
x on "411001"
    /\d{3} \d{3}/    no match
    /\d{3} \d{3}/x   "411001"
U on "<b>bold</b>"
    /<.+>/           "<b>bold</b>"
    /<.+>/U          "<b>"
A on "ab"
    /b/              "b"
    /b/A             no match
D on "a\n"
    /^a$/            "a"
    /^a$/D           no match

Recorded with PHP 8.5.11 (cli) (NTS) on macOS 26 arm64. To run it yourself: php modifiers.php

Two rows deserve a second look. With U, which turns on ungreedy mode: An option of PCRE2, PHP (the U modifier) and Go, also set with (?U) in a pattern, that swaps the two forms: a quantifier without a question mark is lazy and one with it is greedy. JavaScript and Python have no such mode. More in “Greedy and lazy quantifiers”, /<.+>/ stops at the first > and returns <b>, the reverse of what a reader of the pattern expects. And D rejects "a\n" where plain /^a$/ accepts it, 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”: a value that came from a form or a file keeps its final newline, and a bare $ lets it through.

Validating with a bare dollar sign

Wrong: /^\d{6}$/ for a postal code, which accepts "411001\n". Right: /^\d{6}$/D, which ends the match at the true end of the text. The exercise at the end of the lesson turns this into a test.

The u modifier does two jobs, and the next run shows both. It makes \d take ७, as the shorthand classes lesson explains, and it makes PHP check that the text is valid UTF-8: a stray byte makes the call fail instead of matching bytes.

PHP: what the u modifier changes PHP · php-u-flag/u_flag.php
<?php
// The u modifier in PHP: UTF-8 text, Unicode properties for \d, and a check that the subject is valid UTF-8.
$text = "\u{96D}9"; // the Devanagari digit seven, then the ASCII digit 9
foreach (['/\d/', '/\d/u'] as $pattern) {
    preg_match_all($pattern, $text, $found);
    printf("%-6s %s\n", $pattern, json_encode($found[0], JSON_UNESCAPED_UNICODE));
}

// A subject that is not valid UTF-8 (one stray byte): the u pattern refuses to run.
$bytes = "ab\xFF";
foreach (['/./', '/./u'] as $pattern) {
    $count = preg_match_all($pattern, $bytes);
    printf("%-6s %s\n", $pattern, $count === false ? 'false: ' . preg_last_error_msg() : "$count matches");
}

Output

/\d/   ["9"]
/\d/u  ["७","9"]
/./    3 matches
/./u   false: Malformed UTF-8 characters, possibly incorrectly encoded

Recorded with PHP 8.5.11 (cli) (NTS) on macOS 26 arm64. To run it yourself: php u_flag.php

Checkpoint

Check: Delimiters and modifiers in PHP

  1. Question 1 of 2 A PHP pattern must match paths that start with /var/log/. Which string compiles and needs no escaping?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: #^/var/log/#

    The first character is the delimiter, and the same character must close the pattern. Choosing one that does not occur in the body, such as #, saves every backslash.

  2. Question 2 of 2 How many matches does preg_match_all('/\d/u', "१२34") count in PHP 8.5.11?

    Type a number.

    A hint

    The u modifier turns on Unicode properties. Which digits does \d accept then?

    Show the answer: Question 2 of 2

    Answer: 4 matches

    With u the pattern uses Unicode properties, so \d takes the Devanagari digits १ and २ as well as 3 and 4: four matches. Without u the same call counts two.

A failed preg call returns false, not zero

preg_match has three answers: 1 for a match, 0 for none, and false when the call fails. Failure has two causes, and they report differently. A pattern that does not compile raises a PHP warning with the library’s own description, and a run that goes wrong afterwards, such as bad UTF-8 or too much work, is named by preg_last_error_msg(). The run shows a compile failure and why the return value must be tested strictly:

PHP: false is not 0, and where the reason is PHP · php-error/error.php
<?php
// A pattern that does not compile: preg_match() returns false, PHP raises a warning,
// and preg_last_error_msg() has only a general word for it.
$bad = @preg_match('/(/', 'x');
echo "warning: ", error_get_last()['message'], "\n";
echo "last error: ", preg_last_error(), ' = ', preg_last_error_msg(), "\n";

// A good pattern that finds nothing returns 0, which is not false.
$none = preg_match('/zz/', 'x');
var_dump($bad, $none);
var_dump($bad == $none);   // loose comparison: false == 0
var_dump($bad === $none);  // strict comparison tells them apart

foreach ([$bad, $none, preg_match('/x/', 'x')] as $result) {
    echo match (true) {
        $result === false => "error: the pattern or the run failed\n",
        $result === 0     => "no match\n",
        default           => "match\n",
    };
}

Output

warning: preg_match(): Compilation failed: missing closing parenthesis at offset 1
last error: 1 = Internal error
bool(false)
int(0)
bool(true)
bool(false)
error: the pattern or the run failed
no match
match

Recorded with PHP 8.5.11 (cli) (NTS) on macOS 26 arm64. To run it yourself: php error.php

The warning says missing closing parenthesis at offset 1, but preg_last_error_msg() says only Internal error, because the pattern never ran and so there is no run error to describe. The detail lives in the warning, which the @ here hid from the screen but error_get_last() still holds. The loose comparison false == 0 is true, so a check written as if (!$result) treats a broken pattern as an ordinary non-match. Step through the decision once:

Interactive · stage by stage What a preg_match call can answer, and where to read why Step through the three answers of one call: a match, no match, and failure, and see which one needs a second look.
What a preg_match call can answer, and where to read why, step 4 of 4. Showing preg_match call, Test with === (=== false), Read the reason (warning, or last_error_msg). In focus: Read the reason, failed.falsepreg_match callpattern, subjectTest with ====== falseRead the reasonwarning, or last_error_msgfalse

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

Every step as text
  1. One call, one answer. preg_match returns 1 for a match, 0 for no match and false when something went wrong. The three answers come back through the same return value, so the test decides how they are told apart.
  2. A match and no match. A pattern that finds nothing is a normal result, 0, and the subject is fine. A pattern that finds a match gives 1. Neither needs a second look.
  3. Why == is the wrong test. In PHP, false == 0 is true. A check written with == or with ! lumps an error together with no match. Only === false answers the question: did the call fail?
  4. A failed call. After false, two places say more. A compile error is a PHP warning that names the problem and its offset. A run-time failure, such as the backtrack limit, is read with preg_last_error_msg() straight after the call.

A second kind of false comes from the run itself. PHP stops a match after pcre.backtrack_limit steps, set to 1,000,000 here. A pattern with a repeat inside a repeat can need far more than that on text that almost matches, and the call gives up with false. The limit is a safety net, not a setting to tune:

PHP: the backtrack limit stops a nested repeat PHP · php-limit/limit.php
<?php
// The backtrack limit is a safety net: when a run exceeds it, preg_match() returns false
// and preg_last_error_msg() says why. The fix is the pattern, not a bigger limit.
echo "pcre.backtrack_limit = ", ini_get('pcre.backtrack_limit'), "\n";

$subject = str_repeat('a', 26) . '!';
foreach (['/^(a+)+$/' => 'nested repeat', '/^a+$/' => 'one repeat'] as $pattern => $label) {
    $result = preg_match($pattern, $subject);
    printf("%-12s %-14s %s\n", $pattern, $label, $result === false ? 'false: ' . preg_last_error_msg() : "returned $result");
}

Output

pcre.backtrack_limit = 1000000
/^(a+)+$/    nested repeat  false: Backtrack limit exhausted
/^a+$/       one repeat     returned 0

Recorded with PHP 8.5.11 (cli) (NTS) on macOS 26 arm64. To run it yourself: php limit.php

The nested (a+)+ runs into the limit on 26 letters a followed by !, while the single repeat a+ returns 0 straight away. Both accept exactly the same strings, so the fix is the pattern: remove the repeat inside a repeat, and do not raise the limit. Why such patterns blow up, and how to test for them, belongs to the performance and security module later in this track.

Key idea

Test every preg result with === false, read a compile problem in the warning, read a run problem in preg_last_error_msg(), and treat a limit error as a sign that the pattern needs fixing.

Checkpoint

Check: Reading false from a preg call

  1. Question 1 of 3 Which test tells a failed preg_match from a subject that simply has no match?

    Choose one answer.

    Show the answer: Question 1 of 3

    Answer: $result === false

    A match returns 1, no match returns 0 and a failure returns false. The loose comparison treats false and 0 alike, so only === false asks whether the call failed.

  2. Question 2 of 3 After @preg_match('/(/', 'x') returns false, preg_last_error_msg() says Internal error. Where do you read what is wrong with the pattern?

    Choose one answer.

    Show the answer: Question 2 of 3

    Answer: In the PHP warning, which names the problem and its offset

    A pattern that does not compile is reported as a PHP warning with the detail from the library. The preg error functions describe runs, and the pattern never got as far as a run.

  3. Question 3 of 3 preg_match('/^(a+)+$/', ...) on 26 letters a and a ! returns false with Backtrack limit exhausted. What is the right fix?

    Choose one answer.

    Show the answer: Question 3 of 3

    Answer: Write /^a+$/, which accepts the same strings with one repeat

    The limit is a safety net that turned a very long run into false. The cure is a pattern with no nested repeat, which does the same job with one.

Replacements: what only some doors can do

Take one job: turn hello world into World HELLO!, with the second word first and capitalised and the first word in capitals. PHP’s preg_replace reads only three things in a replacement: \n, $n and ${n}, where n is a group number. A group’s name is not one of them, and a digit after $1 is read as part of the number:

PHP: what a preg_replace replacement reads PHP · php-named-replace/named_replace.php
<?php
// preg_replace() reads \n, $n and ${n} in a replacement. Nothing else: a group name is not a reference.
echo preg_replace('/(?<a>\w+)/', '${a}!', 'hi'), "\n";
echo preg_replace('/(\w+)/', '$1!', 'hi'), "\n";
echo preg_replace('/(\w+)/', '${1}!', 'hi'), "\n";

// A digit after $1 is read as part of the group number: $10 is group 10, which does not exist.
echo '[', preg_replace('/(a)/', '$10', 'a'), "]\n";
echo '[', preg_replace('/(a)/', '${1}0', 'a'), "]\n";

// Names and case changes need a function: preg_replace_callback() hands over the groups by name.
echo preg_replace_callback('/(?<w>\w+)/', fn($m) => strtoupper($m['w']) . '!', 'hello world'), "\n";

Output

${a}!
hi!
hi!
[]
[a0]
HELLO! WORLD!

Recorded with PHP 8.5.11 (cli) (NTS) on macOS 26 arm64. To run it yourself: php named_replace.php

The first line shows the name ${a} copied as it stands. $10 means group 10, which does not exist, so it inserts nothing; ${1}0 is group 1 and then a zero. Anything beyond numbered groups goes to preg_replace_callback, which hands a function the groups by name, as the last line does to upper-case both words.

Naming a group in preg_replace

Wrong: preg_replace('/(?<a>\w+)/', '${a}!', 'hi'), which returns ${a}!. Right: '${1}!' for a number, or preg_replace_callback with $m['a'] for a name.

PCRE2 itself can do the whole job. In pcre2test, the replace= modifier gives the replacement text, and substitute_extended makes the library read it fully. Then case conversion escape: A backslash escape in a replacement that changes the case of inserted text: \u and \l change the next letter, \U and \L change everything up to \E. PCRE2 reads them with substitute_extended, and Perl always. work: \u and \l change the next letter, \U and \L change everything up to \E, and ${name} reads a named group. The first block below is the same replacement without the option:

PCRE2: case escapes in a replacement, with and without substitute_extended

PCRE2 10.49 · pcre2-replace/pcre2_replace.py

"""Case-conversion escapes in a PCRE2 replacement, through pcre2test.

The replace= modifier gives the replacement string. Without substitute_extended, the library copies the backslash
sequences as they stand; with it, \\u and \\l change the next letter, \\U and \\L change everything up to \\E, and
${name} reads a named group. The line after the pattern shows the subject; " 1: " is the number of replacements
followed by the new text.
"""

from pcre2_session import run

run(r'''
# the replacement as plain text: no modifier, no case change
/(\w+)\s(\w+)/replace=\u$2 \U$1\E!
    hello world

# the same replacement with substitute_extended
/(\w+)\s(\w+)/replace=\u$2 \U$1\E!,substitute_extended
    hello world

# a named group in the replacement, and \L lowering a shouted word
/(?<city>[A-Z]+), (?<pin>\d{6})/replace=${pin} \L${city}\E,substitute_extended
    PUNE, 411001
''')

Output

$ pcre2test -q patterns.txt   (PCRE2 10.49)
# the replacement as plain text: no modifier, no case change
/(\w+)\s(\w+)/replace=\u$2 \U$1\E!
    hello world
 1: \uworld \Uhello\E!

# the same replacement with substitute_extended
/(\w+)\s(\w+)/replace=\u$2 \U$1\E!,substitute_extended
    hello world
 1: World HELLO!

# a named group in the replacement, and \L lowering a shouted word
/(?<city>[A-Z]+), (?<pin>\d{6})/replace=${pin} \L${city}\E,substitute_extended
    PUNE, 411001
 1: 411001 pune

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 pcre2_replace.py

pcre2-replace/pcre2_session.py

"""Runs a script of patterns and subjects through pcre2test, the test program of the PCRE2 10.49 library, and prints
what it printed.

pcre2test reads a pattern line such as /a.b/ (with modifiers after the closing slash, for example /a.b/dotall,utf),
then one subject per indented line, and prints each subject with " 0:" and the matched text (" 1:" and on for groups)
or "No match". A line starting with # is a comment and is printed as it is. Escapes in a subject such as \\r, \\n or
\\x{2028} stand for those characters (pcre2test's own subject escapes), so invisible characters can be tested.

The script is given in the example file itself, so the example's run record changes whenever the script does:

    from pcre2_session import run
    run(r'''
    /a.b/
        a\\rb
    ''')

The version banner and its release date are not printed (pcre2test -q); the session checks the version itself and
stops if this computer's pcre2test is not PCRE2 10.49, so a record never shows another version's behaviour.
"""

import re
import subprocess
import sys
import tempfile
from pathlib import Path

VERSION = "10.49"
_CANDIDATES = ("/opt/homebrew/bin/pcre2test", "/usr/local/bin/pcre2test")
_ENV = {"PATH": "/usr/bin:/bin", "LC_ALL": "C.UTF-8", "LANG": "C.UTF-8", "TZ": "UTC"}


def _tool() -> str:
    for candidate in _CANDIDATES:
        if Path(candidate).is_file():
            return candidate
    sys.exit("pcre2test (PCRE2 10.49) was not found")


def _check_version(tool: str) -> None:
    first = subprocess.run([tool, "-C"], capture_output=True, text=True, env=_ENV).stdout.splitlines()[:1]
    found = re.match(r"PCRE2 version (\S+)", first[0]) if first else None
    if not found or found.group(1) != VERSION:
        sys.exit(f"this session needs PCRE2 {VERSION}, found: {first[0] if first else 'nothing'}")


def run(script: str) -> None:
    """Prints the pcre2test transcript of the script (patterns, subjects and results)."""
    tool = _tool()
    _check_version(tool)
    lines = script.strip("\n").splitlines()
    text = "\n".join(_dedent(lines)) + "\n"
    with tempfile.TemporaryDirectory() as folder:
        source = Path(folder) / "patterns.txt"
        source.write_text(text, encoding="utf-8")
        done = subprocess.run([tool, "-q", str(source)], capture_output=True, text=True, encoding="utf-8", env=_ENV)
    print(f"$ pcre2test -q patterns.txt   (PCRE2 {VERSION})")
    sys.stdout.write(done.stdout)
    if done.stderr:
        sys.stdout.write(done.stderr)
    if done.returncode != 0:
        print(f"[exit status {done.returncode}]")
    sys.stdout.flush()


def _dedent(lines: list[str]) -> list[str]:
    """Removes the indentation the whole script shares, so it can sit indented inside a Python call."""
    widths = [len(line) - len(line.lstrip(" ")) for line in lines if line.strip()]
    cut = min(widths) if widths else 0
    return [line[cut:] for line in lines]

Without the option the library copies the backslashes: \uworld \Uhello\E!. With it the line reads World HELLO!, and the third block swaps two named groups and lowers one. The manual adds that a \u straight before a \L makes a title case, so a group holding heLLo comes out as Hello. The case escapes act on everything inserted after them, literal text included.

Perl builds the same result without any option. The replacement part of s/// is an interpolated string, so the case escapes of perlop work in it, and $+{name} reads a named group (perlre). The /r modifier returns the changed copy and leaves the original string alone. In Perl, s/(\w+)\s(\w+)/\u$2 \U$1\E!/r on hello world gives World HELLO!, and s/(\w+)/\u$1/g gives Hello World. \u is documented as title-casing the next character, which for a lone letter is the capital.

Key idea

PHP’s preg_replace takes numbers only; PCRE2 with substitute_extended and Perl read case escapes and names in the replacement itself, and in PHP a callback does that work.

Checkpoint

Check: What a replacement can hold

  1. Question 1 of 3 What does preg_replace('/(?<a>\w+)/', '${a}!', 'hi') return?

    Choose one answer.

    Show the answer: Question 1 of 3

    Answer: ${a}!

    The replacement of preg_replace knows numbers only. Named groups need preg_replace_callback, which hands the function an array keyed by name.

  2. Question 2 of 3 In pcre2test, /(\w+)\s(\w+)/replace=\u$2 \U$1\E! on hello world prints \uworld \Uhello\E!. What turns it into World HELLO!?

    Choose one answer.

    Show the answer: Question 2 of 3

    Answer: Adding the substitute_extended modifier

    Without the option only the dollar sign is special in a replacement. With substitute_extended the library reads backslash escapes, and the case escapes act on everything inserted after them.

  3. Question 3 of 3 Which doors read a replacement that changes case and names a group, without a callback function?

    Choose every answer that is right.

    Show the answer: Question 3 of 3

    Answer:

    • pcre2test with substitute_extended
    • Perl s///

    PCRE2 itself can do both once substitute_extended is on, and so can Perl; PHP's preg_replace keeps to numbered references and sends the rest to preg_replace_callback.

pcre2grep and pcre2test show what the library does

pcre2grep filters files the way grep does, with the PCRE2 dialect. By default it prints each line that matches. With -o it prints only the matched part, with -o3 only what capturing group 3 captured, and it prints nothing for a line that does not match. pcre2test is the workbench behind it: it reads patterns and subjects from a script and prints the match and every group for each subject. The run below models a pcre2grep search of four synthetic access-log lines, keeping only failed requests (status 4xx or 5xx), with private addresses:

pcre2test: the pieces a pcre2grep search for failed requests would print

PCRE2 10.49 · pcre2grep-logs/pcre2grep_demo.py

"""What pcre2grep prints for failed requests, modelled with pcre2test on four synthetic access-log lines.

pcre2grep -o shows only the part of a line that matched, -o1 only what group 1 captured, and a line that does not match
prints nothing. pcre2test prints the same pieces numbered: " 0:" is the whole match (what -o shows), " 1:" and on are
the groups (-o1, -o2 ...), and "No match" is a line grep would skip. A subject modifier get=name, written after \\=,
prints one group by its name. Addresses are private ones.
"""

from pcre2_session import run

run(r'''
# lines of the form: address method path status; keep the 4xx and 5xx ones
/^(?<ip>\d+\.\d+\.\d+\.\d+) (?<method>[A-Z]+) (?<path>\S+) (?<status>[45]\d\d)$/
    10.0.4.17 GET /learn/regex/ 200
    10.0.4.17 GET /learn/missing 404
    192.168.1.9 POST /api/login 500
    192.168.1.9 GET /feed 503

# the same pattern, asking for two of the named groups by name
/^(?<ip>\d+\.\d+\.\d+\.\d+) (?<method>[A-Z]+) (?<path>\S+) (?<status>[45]\d\d)$/
    192.168.1.9 POST /api/login 500\=get=path,get=status
''')

Output

$ pcre2test -q patterns.txt   (PCRE2 10.49)
# lines of the form: address method path status; keep the 4xx and 5xx ones
/^(?<ip>\d+\.\d+\.\d+\.\d+) (?<method>[A-Z]+) (?<path>\S+) (?<status>[45]\d\d)$/
    10.0.4.17 GET /learn/regex/ 200
No match
    10.0.4.17 GET /learn/missing 404
 0: 10.0.4.17 GET /learn/missing 404
 1: 10.0.4.17
 2: GET
 3: /learn/missing
 4: 404
    192.168.1.9 POST /api/login 500
 0: 192.168.1.9 POST /api/login 500
 1: 192.168.1.9
 2: POST
 3: /api/login
 4: 500
    192.168.1.9 GET /feed 503
 0: 192.168.1.9 GET /feed 503
 1: 192.168.1.9
 2: GET
 3: /feed
 4: 503

# the same pattern, asking for two of the named groups by name
/^(?<ip>\d+\.\d+\.\d+\.\d+) (?<method>[A-Z]+) (?<path>\S+) (?<status>[45]\d\d)$/
    192.168.1.9 POST /api/login 500\=get=path,get=status
 0: 192.168.1.9 POST /api/login 500
 1: 192.168.1.9
 2: POST
 3: /api/login
 4: 500
  G /api/login (10) path (group 3)
  G 500 (3) status (group 4)

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 pcre2grep_demo.py

pcre2grep-logs/pcre2_session.py

"""Runs a script of patterns and subjects through pcre2test, the test program of the PCRE2 10.49 library, and prints
what it printed.

pcre2test reads a pattern line such as /a.b/ (with modifiers after the closing slash, for example /a.b/dotall,utf),
then one subject per indented line, and prints each subject with " 0:" and the matched text (" 1:" and on for groups)
or "No match". A line starting with # is a comment and is printed as it is. Escapes in a subject such as \\r, \\n or
\\x{2028} stand for those characters (pcre2test's own subject escapes), so invisible characters can be tested.

The script is given in the example file itself, so the example's run record changes whenever the script does:

    from pcre2_session import run
    run(r'''
    /a.b/
        a\\rb
    ''')

The version banner and its release date are not printed (pcre2test -q); the session checks the version itself and
stops if this computer's pcre2test is not PCRE2 10.49, so a record never shows another version's behaviour.
"""

import re
import subprocess
import sys
import tempfile
from pathlib import Path

VERSION = "10.49"
_CANDIDATES = ("/opt/homebrew/bin/pcre2test", "/usr/local/bin/pcre2test")
_ENV = {"PATH": "/usr/bin:/bin", "LC_ALL": "C.UTF-8", "LANG": "C.UTF-8", "TZ": "UTC"}


def _tool() -> str:
    for candidate in _CANDIDATES:
        if Path(candidate).is_file():
            return candidate
    sys.exit("pcre2test (PCRE2 10.49) was not found")


def _check_version(tool: str) -> None:
    first = subprocess.run([tool, "-C"], capture_output=True, text=True, env=_ENV).stdout.splitlines()[:1]
    found = re.match(r"PCRE2 version (\S+)", first[0]) if first else None
    if not found or found.group(1) != VERSION:
        sys.exit(f"this session needs PCRE2 {VERSION}, found: {first[0] if first else 'nothing'}")


def run(script: str) -> None:
    """Prints the pcre2test transcript of the script (patterns, subjects and results)."""
    tool = _tool()
    _check_version(tool)
    lines = script.strip("\n").splitlines()
    text = "\n".join(_dedent(lines)) + "\n"
    with tempfile.TemporaryDirectory() as folder:
        source = Path(folder) / "patterns.txt"
        source.write_text(text, encoding="utf-8")
        done = subprocess.run([tool, "-q", str(source)], capture_output=True, text=True, encoding="utf-8", env=_ENV)
    print(f"$ pcre2test -q patterns.txt   (PCRE2 {VERSION})")
    sys.stdout.write(done.stdout)
    if done.stderr:
        sys.stdout.write(done.stderr)
    if done.returncode != 0:
        print(f"[exit status {done.returncode}]")
    sys.stdout.flush()


def _dedent(lines: list[str]) -> list[str]:
    """Removes the indentation the whole script shares, so it can sit indented inside a Python call."""
    widths = [len(line) - len(line.lstrip(" ")) for line in lines if line.strip()]
    cut = min(widths) if widths else 0
    return [line[cut:] for line in lines]

The first log line has status 200, so pcre2test prints No match, where pcre2grep would stay silent. For the other three, 0: is the whole match, which is what -o prints, and 1: to 4: are the four groups that -o1 to -o4 pick. A subject modifier after \=, here get=path,get=status, prints groups by their names.

The same program measures how much work a pattern costs, which is the match limit: A cap on the steps of the matching loop of PCRE2 that turns a runaway match into an error instead of an endless run. pcre2test reports error -47 when it is reached, and PHP returns false.. PCRE2 counts the steps of its main matching loop, and the limit stops a match when the count is reached. find_limits asks pcre2test for the smallest limit that still lets a match finish, and match_limit=N sets one:

pcre2test: the smallest match limit that lets a nested repeat finish

PCRE2 10.49 · pcre2-find-limits/pcre2_find_limits.py

"""The match limit of PCRE2, measured and enforced through pcre2test.

find_limits and match_limit=N are modifiers of a subject line, written after \\= at its end. find_limits makes the
library search for the smallest limits that still let the match finish; match_limit=N stops a match after N steps of
its main loop and reports error -47. The pattern is the nested repeat of the lesson; every subject ends in a character
that makes each attempt fail. The first three subjects have 14, 18 and 22 letters a; the fourth has the 26 letters of
the PHP run.
"""

from pcre2_session import run

run(r'''
/^(a+)+$/
    aaaaaaaaaaaaaa!\=find_limits_noheap
    aaaaaaaaaaaaaaaaaa!\=find_limits_noheap
    aaaaaaaaaaaaaaaaaaaaaa!\=find_limits_noheap
    aaaaaaaaaaaaaaaaaaaaaaaaaa!\=match_limit=1000

# one repeat: the same subject fails at once
/^a+$/
    aaaaaaaaaaaaaaaaaaaaaaaaaa!\=find_limits_noheap
''')

Output

$ pcre2test -q patterns.txt   (PCRE2 10.49)
/^(a+)+$/
    aaaaaaaaaaaaaa!\=find_limits_noheap
Minimum match limit = 40960
Minimum depth limit = 31
No match
    aaaaaaaaaaaaaaaaaa!\=find_limits_noheap
Minimum match limit = 655360
Minimum depth limit = 39
No match
    aaaaaaaaaaaaaaaaaaaaaa!\=find_limits_noheap
Minimum match limit = 10485760
Minimum depth limit = 47
No match
    aaaaaaaaaaaaaaaaaaaaaaaaaa!\=match_limit=1000
Failed: error -47: match limit exceeded

# one repeat: the same subject fails at once
/^a+$/
    aaaaaaaaaaaaaaaaaaaaaaaaaa!\=find_limits_noheap
Minimum match limit = 2
Minimum depth limit = 2
No match

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 pcre2_find_limits.py

pcre2-find-limits/pcre2_session.py

"""Runs a script of patterns and subjects through pcre2test, the test program of the PCRE2 10.49 library, and prints
what it printed.

pcre2test reads a pattern line such as /a.b/ (with modifiers after the closing slash, for example /a.b/dotall,utf),
then one subject per indented line, and prints each subject with " 0:" and the matched text (" 1:" and on for groups)
or "No match". A line starting with # is a comment and is printed as it is. Escapes in a subject such as \\r, \\n or
\\x{2028} stand for those characters (pcre2test's own subject escapes), so invisible characters can be tested.

The script is given in the example file itself, so the example's run record changes whenever the script does:

    from pcre2_session import run
    run(r'''
    /a.b/
        a\\rb
    ''')

The version banner and its release date are not printed (pcre2test -q); the session checks the version itself and
stops if this computer's pcre2test is not PCRE2 10.49, so a record never shows another version's behaviour.
"""

import re
import subprocess
import sys
import tempfile
from pathlib import Path

VERSION = "10.49"
_CANDIDATES = ("/opt/homebrew/bin/pcre2test", "/usr/local/bin/pcre2test")
_ENV = {"PATH": "/usr/bin:/bin", "LC_ALL": "C.UTF-8", "LANG": "C.UTF-8", "TZ": "UTC"}


def _tool() -> str:
    for candidate in _CANDIDATES:
        if Path(candidate).is_file():
            return candidate
    sys.exit("pcre2test (PCRE2 10.49) was not found")


def _check_version(tool: str) -> None:
    first = subprocess.run([tool, "-C"], capture_output=True, text=True, env=_ENV).stdout.splitlines()[:1]
    found = re.match(r"PCRE2 version (\S+)", first[0]) if first else None
    if not found or found.group(1) != VERSION:
        sys.exit(f"this session needs PCRE2 {VERSION}, found: {first[0] if first else 'nothing'}")


def run(script: str) -> None:
    """Prints the pcre2test transcript of the script (patterns, subjects and results)."""
    tool = _tool()
    _check_version(tool)
    lines = script.strip("\n").splitlines()
    text = "\n".join(_dedent(lines)) + "\n"
    with tempfile.TemporaryDirectory() as folder:
        source = Path(folder) / "patterns.txt"
        source.write_text(text, encoding="utf-8")
        done = subprocess.run([tool, "-q", str(source)], capture_output=True, text=True, encoding="utf-8", env=_ENV)
    print(f"$ pcre2test -q patterns.txt   (PCRE2 {VERSION})")
    sys.stdout.write(done.stdout)
    if done.stderr:
        sys.stdout.write(done.stderr)
    if done.returncode != 0:
        print(f"[exit status {done.returncode}]")
    sys.stdout.flush()


def _dedent(lines: list[str]) -> list[str]:
    """Removes the indentation the whole script shares, so it can sit indented inside a Python call."""
    widths = [len(line) - len(line.lstrip(" ")) for line in lines if line.strip()]
    cut = min(widths) if widths else 0
    return [line[cut:] for line in lines]

For ^(a+)+$ the minimum is 40,960 steps with 14 letters, 655,360 with 18 and 10,485,760 with 22: four more letters multiply the work by 16. With a limit of 1000 the 26-letter subject of the PHP run ends as error -47, a failure that is not the same as No match. The single repeat ^a+$ needs 2. pcre2grep takes --match-limit for the same purpose, and PHP’s pcre.backtrack_limit is the same kind of guard: they turn a very long run into an error, and the pattern still needs fixing.

Check the growth yourself

Copy the script to a terminal with pcre2test 10.49 and try 16 and 20 letters a. Each four letters should multiply the minimum limit by 16, as the three recorded lines do.

Checkpoint

Check: pcre2grep, pcre2test and the match limit

  1. Question 1 of 2 Which pcre2grep option prints only the part of a line that matched?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: -o

    pcre2grep prints whole matching lines by default; -o narrows the output to the match, and a number after it picks a capturing group. A line that does not match prints nothing.

  2. Question 2 of 2 The subject line aaaaaaaaaaaaaaaaaaaaaaaaaa!\=match_limit=1000 is tried against ^(a+)+$ in pcre2test. What does it print?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: Failed: error -47: match limit exceeded

    match_limit=N caps the steps of the main matching loop. When the cap is hit before the answer is known, the result is error -47, which is not the same as no match.

Choosing a front door for the job

The choice depends on where the pattern has to run and on what the replacement must hold. Answer the two questions in the table and watch which doors are ruled out, and why:

Interactive · answer and compare Which front door fits the job? Answer two questions about the job and see which door is ruled out, and what the others cost.
Where does the pattern have to run?
Does the replacement change case or use group names?

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

pcre2test and pcre2grep, PHP preg functions and Perl, against four things a job can need
Front door See what a pattern does Change case in a replacement Name a group in a replacement Say why a pattern failed
pcre2test and pcre2grep You write: a command on files or a test script Good pcre2test prints every group; pcre2grep -o prints the match Good substitute_extended gives \u, \l, \U, \L and \E Good ${name} works with substitute_extended Good error 114 at offset 1: missing closing parenthesis
PHP preg functions You write: a call inside a PHP program Mixed you print the match array yourself in a script Poor preg_replace has no case escapes: use a callback Poor only \n, $n and ${n} are read: names need a callback Mixed false, and the detail is in a warning, not in the error message
Perl s/// and m// You write: an operator in a Perl script Mixed you print the captures yourself in a script Good \u, \l, \U, \L and \E work in the replacement Good $+{name} is read in the replacement Good the program stops with the spot marked <-- HERE
How the answers decide
  • “Where does the pattern have to run?” Inside a PHP application: rules out pcre2test and pcre2grep. A PHP application calls the preg functions in its own process; pcre2grep and pcre2test are programs to start, which fit a pipeline, not a request.
  • “Where does the pattern have to run?” Inside a PHP application: adds a caveat to Perl s/// and m//. Perl runs an engine of its own, so a pattern tested there is a second dialect to check before PHP runs it.
  • “Where does the pattern have to run?” On files or a stream, from a shell: adds a caveat to PHP preg functions. A PHP script must open and loop over the files itself, where pcre2grep takes the files as arguments.
  • “Where does the pattern have to run?” In a script that rewrites text: rules out pcre2test and pcre2grep. pcre2grep only reads files and pcre2test only tests patterns: neither rewrites a text for you.
  • “Does the replacement change case or use group names?” Yes, one or both: rules out PHP preg functions. preg_replace reads \n, $n and ${n} only; case changes and names need preg_replace_callback and a function.
  • “Does the replacement change case or use group names?” No, only numbered groups: adds a caveat to pcre2test and pcre2grep. With only numbered groups every door works; the shell tools give the most detail while you are still checking the pattern.

Each answer rules out a door only for the reason the table gives, and a ruled-out door can still be the right one for a different job: the same person often keeps pcre2test open beside a PHP editor.

In short: use pcre2test and pcre2grep to see what a pattern does and to filter files, use PHP’s functions inside a PHP program and keep to numbered groups or a callback in replacements, and use Perl for a rewrite that needs case escapes or names in one string. Test the pattern in the engine that will run it, since Perl and PCRE2 are close and not the same.

Checkpoint

Check: Choosing a front door

  1. One question A pattern works in Perl. Why test it again before it goes into a PHP service?

    Choose one answer.

    Show the answer: One question

    Answer: Perl runs its own engine, and PCRE2 matches it closely but with documented differences

    The two engines aim at the same syntax and semantics, but they are two engines. A pattern is checked in the engine that will run it.

Practise: validators that explain a bad pattern

The exercise rebuilds the lesson’s habits in one small module: three field validators that anchor the whole text, take only ASCII digits and letters and refuse a final newline, and a pattern compiler that reports a bad pattern with its position instead of raising. It is written in Python, the language its tests run in, in your browser; the ideas carry over, and re.fullmatch does the work of PHP’s anchors and D.

Exercise · Medium · Python

Field validators that explain a bad pattern

Write two functions in validators.py. The lesson's runs are PHP code; this exercise is written in Python, so the same ideas are written with Python's re, where the pattern is a plain string and re.fullmatch plays the part of PHP's D modifier and anchors.

check_field(kind, text) validates one field of a form and returns a dict. The three kinds are shapes this exercise defines (they copy no real postal, banking or telephone scheme):

  • "postal": six digits, the first one from 1 to 9. One space is allowed after the third digit, as in 411 001. The value is the six digits without the space.
  • "branch": four letters followed by seven digits, such as ABCD1234567. The letters may be upper or lower case. The value is the code in upper case.
  • "mobile": ten digits, the first one from 6 to 9. One space is allowed after the fifth digit, as in 98765 43210. The value is the ten digits without the space.

A good field returns {"ok": True, "value": <the normalised value>}. A bad field returns {"ok": False, "error": "not a valid postal code"}, with postal code, branch code or mobile number in the message. An unknown kind returns {"ok": False, "error": "unknown kind: pan"} (with the kind that was passed).

Only the ASCII digits 0 to 9 and the ASCII letters count: the Devanagari digits ४११००१ are not a postal code. A text with a trailing newline, such as "411001\n", is not valid either, because the whole text must fit the shape.

compile_checked(pattern) compiles a pattern the user typed and never raises. It returns (compiled, None) when the pattern is good, and (None, message) when it is not, where the message reads pattern error at position 0: followed by the library's own description. The position is the one the re.error reports, the way a PHP program reads the offset in a compile warning.

check_field("postal", "411 001")   # {"ok": True, "value": "411001"}
check_field("branch", "abcd1234567")  # {"ok": True, "value": "ABCD1234567"}
compile_checked("(")               # (None, "pattern error at position 0: missing ), unterminated subpattern")

The starter checks postal codes with re.match and $, which is where the sample tests show two bugs: a final newline slips through, and digits of other scripts are accepted. The other two kinds and the error report are yours.

Starter code · validators.py

import re


def check_field(kind, text):
    """Validates one form field and returns {"ok": True, "value": ...} or {"ok": False, "error": ...}."""
    if kind == "postal":
        # Bug: `$` also matches before a final newline, and `\d` takes digits of other scripts.
        if re.match(r"^\d{6}$", text):
            return {"ok": True, "value": text}
        return {"ok": False, "error": "not a valid postal code"}
    return {"ok": False, "error": "unknown kind: " + str(kind)}


def compile_checked(pattern):
    """Compiles a user's pattern and returns (compiled, None) or (None, "pattern error at position N: ...")."""
    return re.compile(pattern), None
The sample tests · test_validators.py
from validators import check_field, compile_checked


def test_postal_plain_and_spaced():
    """six digits, or three, one space and three; the value has no space"""
    assert check_field("postal", "411001") == {"ok": True, "value": "411001"}
    assert check_field("postal", "411 001") == {"ok": True, "value": "411001"}


def test_postal_shapes_that_fail():
    """a leading zero, a short code and two spaces are refused with the kind's message"""
    for text in ("011001", "41100", "411  001", "4110011"):
        assert check_field("postal", text) == {"ok": False, "error": "not a valid postal code"}


def test_postal_trailing_newline():
    """the whole text must fit the shape: a final newline is not allowed"""
    assert check_field("postal", "411001\n")["ok"] is False


def test_postal_ascii_digits_only():
    """Devanagari digits are digits to Python's \\d but are not a postal code here"""
    assert check_field("postal", "४११००१")["ok"] is False


def test_branch_code():
    """four letters and seven digits; lower case in, upper case out"""
    assert check_field("branch", "ABCD1234567") == {"ok": True, "value": "ABCD1234567"}
    assert check_field("branch", "abcd1234567") == {"ok": True, "value": "ABCD1234567"}
    for text in ("ABCD123456", "ABC12345678", "ABCD12345678", "ABCD123456X", "ABCD1234567\n"):
        assert check_field("branch", text) == {"ok": False, "error": "not a valid branch code"}


def test_mobile_number():
    """ten digits starting 6 to 9, with an optional space after the fifth"""
    assert check_field("mobile", "9876543210") == {"ok": True, "value": "9876543210"}
    assert check_field("mobile", "98765 43210") == {"ok": True, "value": "9876543210"}
    for text in ("5876543210", "987654321", "98765  43210", "98765-43210"):
        assert check_field("mobile", text) == {"ok": False, "error": "not a valid mobile number"}


def test_unknown_kind():
    """a kind the function does not know is reported, not raised"""
    assert check_field("pan", "x") == {"ok": False, "error": "unknown kind: pan"}


def test_compile_checked_good_pattern():
    """a good pattern comes back compiled, with no message"""
    compiled, error = compile_checked("[0-9]{6}")
    assert error is None
    assert compiled.fullmatch("411001")


def test_compile_checked_bad_pattern():
    """a bad pattern gives no compiled object and a message with the position"""
    compiled, error = compile_checked("(")
    assert compiled is None
    assert error.startswith("pattern error at position 0: ")
    compiled, error = compile_checked("ab)")
    assert compiled is None
    assert error.startswith("pattern error at position 2: ")
A hint

Compile one pattern per kind and test the whole text with re.fullmatch, which cannot accept a trailing newline the way $ does. Write the digits as [0-9] instead of \d, so that Devanagari digits are refused, and capture the two halves of the postal and mobile numbers as groups so that the optional space drops out when you join them. For compile_checked, catch re.error and read its pos and msg.

The sample tests run on this device, in your browser (Pyodide): nothing is sent to mysmartcopilot.com. The first run downloads 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 Try a pattern from this lesson on sample lines. The tester runs your browser's own engine, the JavaScript flavour, so a PCRE2 feature such as an inline modifier may behave differently there.

Last, a round of questions on all six ideas. It opens on the one you handled least well and tells you why each answer is right or wrong the moment you pick it:

Practice that adapts to you

Practise this lesson

19 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 19 Why can a feature of PCRE2 work in pcre2test and fail in a PHP build?

    Choose one answer.

    Show the answer: Question 1 of 19

    Answer: They are two builds of the library: 10.49 for pcre2test here and 10.44 inside PHP 8.5.11

    The version run prints 10.44 for PHP, and the pcre2test transcripts say 10.49. Same library, different releases.

  2. Question 2 of 19 How are PCRE2 and Perl related?

    Choose one answer.

    Show the answer: Question 2 of 19

    Answer: PCRE2 follows Perl's syntax and semantics as closely as it can, but it is a separate engine

    The syntax is shared on purpose, the code is not. That is why a pattern usually moves between them and occasionally does not.

  3. Question 3 of 19 Case-insensitive matching must be switched on in pcre2test, in PHP and in Perl. Where does each put the i?

    Choose one answer.

    Show the answer: Question 3 of 19

    Answer: After the closing slash of /re/i, after the closing delimiter of the PHP string, after the last delimiter of s///i

    The letters come after the pattern in every door. What differs is the frame around the pattern, which is why the same i looks different in each.

  4. Question 4 of 19 In the PHP pattern string ~\d{3}~i, what are the delimiter and the modifier?

    Choose one answer.

    Show the answer: Question 4 of 19

    Answer: The delimiter is ~ and the modifier is i

    A PHP pattern string is delimiter, body, delimiter, modifiers. The tilde is one of the usual delimiters, with the slash and the hash.

  5. Question 5 of 19 A validator accepts "411001\n" with preg_match('/^\d{6}$/', $input). Which change rejects it?

    Choose one answer.

    Show the answer: Question 5 of 19

    Answer: Add the D modifier: /^\d{6}$/D

    By default $ also matches just before a final newline. D removes that, which matters for values that come from forms and files.

  6. Question 6 of 19 What does preg_match('/<.+>/U', '<b>bold</b>', $m) leave in $m[0]?

    Choose one answer.

    Show the answer: Question 6 of 19

    Answer: <b>

    U is PHP's name for the ungreedy option. Quantifiers become lazy by default and greedy when followed by a question mark.

  7. Question 7 of 19 What does preg_match_all('/./u', "ab\xFF") return?

    Choose one answer.

    Show the answer: Question 7 of 19

    Answer: false, and preg_last_error_msg() says the UTF-8 is malformed

    The u modifier asks for UTF-8 checking as well as Unicode behaviour. A subject with a stray byte makes the call fail, which is another reason to test the result with ===.

  8. Question 8 of 19 What does preg_match('/zz/', 'x') return?

    Choose one answer.

    Show the answer: Question 8 of 19

    Answer: 0

    The three answers are 1, 0 and false. Only the last one is a failure.

  9. Question 9 of 19 What is the value of pcre.backtrack_limit printed by the limit run?

    Type a number.

    A hint

    It is one million.

    Show the answer: Question 9 of 19

    Answer: 1000000 backtracks

    PHP's default is 1000000. A run that needs more returns false, and preg_last_error_msg() then says Backtrack limit exhausted.

  10. Question 10 of 19 Which text does the warning of preg_match('/(/', 'x') give for the problem?

    Choose one answer.

    Show the answer: Question 10 of 19

    Answer: Compilation failed: missing closing parenthesis at offset 1

    The warning carries the library's message and an offset into the pattern body. preg_last_error_msg() describes runs, so for a compile failure it says only Internal error.

  11. Question 11 of 19 Why does preg_replace('/(a)/', '$10', 'a') return an empty string, and how do you write "group 1, then a zero"?

    Choose one answer.

    Show the answer: Question 11 of 19

    Answer: $10 reads as group 10, which does not exist, so nothing is inserted; write ${1}0

    A digit after $1 is taken as part of the group number. The braces of ${1} fence the number off from the digit that follows.

  12. Question 12 of 19 In pcre2test with substitute_extended, /(?<city>[A-Z]+), (?<pin>\d{6})/replace=${pin} \L${city}\E is run on PUNE, 411001. What is printed?

    Choose one answer.

    Show the answer: Question 12 of 19

    Answer: 411001 pune

    The extended form reads ${name} for a named group and \L...\E for lower case, so the city comes out lowered and second.

  13. Question 13 of 19 What does $new = $old =~ s/a/b/r do in Perl?

    Choose one answer.

    Show the answer: Question 13 of 19

    Answer: It puts the changed copy in $new and leaves $old as it was

    /r turns the substitution into an expression with a value, which is why it chains and works inside map.

  14. Question 14 of 19 Group 1 holds heLLo. What does the extended replacement \u\L$1 insert?

    Choose one answer.

    Show the answer: Question 14 of 19

    Answer: Hello

    The pcre2api manual describes \u followed by \L as title casing for a captured group. Run in pcre2test it turns heLLo into Hello.

  15. Question 15 of 19 The log line 10.0.4.17 GET /learn/regex/ 200 makes pcre2test print No match for the 4xx and 5xx pattern. What does pcre2grep do with it?

    Choose one answer.

    Show the answer: Question 15 of 19

    Answer: It prints nothing for that line

    pcre2test reports every subject, which suits testing; pcre2grep is a filter and prints only what matched.

  16. Question 16 of 19 What does the subject modifier \=get=path add to a pcre2test result?

    Choose one answer.

    Show the answer: Question 16 of 19

    Answer: A line that shows the text captured by the group named path

    get= takes a group number or name and prints that group with its length and name, which is a way to read named groups in a test script.

  17. Question 17 of 19 On ^(a+)+$ the minimum match limit was 655,360 for 18 letters a and 10,485,760 for 22. How many times larger is the second?

    Type a number.

    A hint

    Divide 10,485,760 by 655,360.

    Show the answer: Question 17 of 19

    Answer: 16 times

    The ratio is 16, and the 14-letter run shows the same factor one step earlier (40,960 to 655,360). Four more letters multiply the work by 16, so doubling the limit would buy only one more letter.

  18. Question 18 of 19 A PHP service must put a group name and an upper-case word in one replacement. What does it use?

    Choose one answer.

    Show the answer: Question 18 of 19

    Answer: preg_replace_callback with a function that builds the text

    PHP has one route for anything beyond numbered groups: a callback. It costs a function and gives full control.

  19. Question 19 of 19 A job must rewrite headings in a pile of text files. Why is pcre2grep not the whole answer?

    Choose one answer.

    Show the answer: Question 19 of 19

    Answer: It reads and prints matches; it does not rewrite files, so another tool must write the result

    pcre2grep is a searching and extracting tool. Rewriting needs a script in Perl or PHP, or another program to write the output back.

Interview questions

Warm-up: in PHP, which delimiter would you choose for a pattern that matches paths such as /learn/regex/, and where do the modifier letters go?

Choose a delimiter that does not occur in the pattern. A path pattern is full of slashes, so / would need a backslash before each one, as in /^\/learn\/regex\//. A hash avoids that: #^/learn/regex/#. Braces work as a pair too: {^/learn/regex/}. The first character of the string is the delimiter, and the same character, or the closing bracket of a pair, ends the body. Everything after it is read as modifier letters, so a case-insensitive version is #^/learn/regex/#i. A delimiter that stays inside the body must be escaped, and a letter, a digit, a backslash or white space is refused. When the delimiter comes from a variable, preg_quote($text, '#') escapes it for the body. The delimiter never changes what the pattern matches, only how readable it is.

Two harder questions follow, one on a failed call that reports almost nothing and one on a replacement PHP cannot read. 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. Fresher to staff

    A PHP service calls @preg_match('/(/', 'x'). It returns false, and preg_last_error_msg() says Internal error. Explain what happened, where the real reason is, and the two habits that stop this bug from reaching production.

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

  2. Fresher to staff

    Write the PCRE2 replacement that turns hello world into World HELLO! with extended substitution. Say which modifier makes it work, what it prints without it, and why PHP's preg_replace cannot do the same.

    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 do PHP, pcre2test and Perl share, and what differs between them?

    PHP and the pcre2test and pcre2grep programs share PCRE2's pattern language in different releases (10.44 in PHP 8.5.11, 10.49 for the programs), and Perl runs its own engine that PCRE2 imitates. Options and replacements belong to each door.

  2. Which delimiter suits a PHP pattern for paths such as /learn/regex/, and where do modifiers go?

    A character the pattern does not contain, such as #^/learn/regex/#, or a pair of braces, so no slash needs a backslash. Modifier letters follow the closing delimiter, as in #^/learn/regex/#i.

  3. Why does /^\d{6}$/ accept a postal code followed by a newline in PHP, and what fixes it?

    PCRE2's $ also holds before a final newline. The D modifier, /^\d{6}$/D, makes $ hold only at the true end of the text.

  4. What does the u modifier change for a PHP pattern?

    PCRE2 reads the pattern and the text as UTF-8, and \d takes Unicode digits too, so without u a Devanagari digit such as ७ is skipped.

  5. How should you test what preg_match returned?

    With === false. It returns 1, 0 or false, and only false means the call failed: read a compile problem in the warning and a run problem in preg_last_error_msg().

  6. What may a preg_replace replacement contain, and where do case escapes and names work?

    Only \n, $n and ${n}. PCRE2 with substitute_extended and Perl also read case escapes and group names in the replacement; in PHP, preg_replace_callback does that work.

  7. What does PCRE2's match limit do when a pattern runs away?

    It counts matching steps and turns the runaway match into an error: false in PHP and error -47 in pcre2test. The cure is a pattern without a repeat inside a repeat.

Key takeaways

  • PHP, pcre2test and pcre2grep share PCRE2’s pattern language, but they carry different releases of it (10.44 and 10.49 here), and Perl runs an engine of its own that PCRE2 only imitates.
  • A PHP pattern is a string whose first character is the delimiter, and the letters after the closing delimiter are modifiers, such as u for UTF-8 and Unicode digits and D for a $ that ignores a final newline.
  • preg_match returns 1, 0 or false, so test it with === false; read a compile problem in the warning and a run problem in preg_last_error_msg().
  • A replacement for preg_replace can hold only \n, $n and ${n}, while PCRE2 with substitute_extended and Perl also read case escapes and group names, and PHP does the same work in preg_replace_callback.
  • The match limit counts matching steps and turns a runaway pattern into an error, false in PHP and error -47 in pcre2test; the cure is a pattern without a repeat inside a repeat.

Terms in this lesson

case conversion escape
A backslash escape in a replacement that changes the case of inserted text: \u and \l change the next letter, \U and \L change everything up to \E. PCRE2 reads them with substitute_extended, and Perl always.
match limit
A cap on the steps of the matching loop of PCRE2 that turns a runaway match into an error instead of an endless run. pcre2test reports error -47 when it is reached, and PHP returns false.
pattern delimiter
The character that opens and closes a pattern written as a string, as in PHP's preg functions. It is the string's first character and must end the body; modifier letters follow the closing one.
pattern modifier
A letter written after the closing delimiter of a pattern that sets an option for the whole pattern, such as i for case-insensitive matching, m for line anchors or u for UTF-8 and Unicode classes.

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.