Three APIs for the same job

Every language with regular expressions offers the same handful of operations: does the text match, where is the first match, give me all matches, replace them, and split the text at them. The names differ more than the ideas:

Task Python (re) JavaScript PHP
Is there a match? re.search(p, s) is not None re.test(s) preg_match(p, s) === 1
Whole text must match re.fullmatch(p, s) anchor it: /^…$/ anchor it: '/^…$/D'
First match with groups re.search(p, s) re.exec(s), s.match(re) preg_match(p, s, $m)
All matches re.findall, re.finditer s.match(/…/g), s.matchAll(/…/g) preg_match_all(p, s, $m)
Replace re.sub(p, repl, s) s.replace(re, repl), replaceAll preg_replace, preg_replace_callback
Split re.split(p, s) s.split(re) preg_split(p, s)
Escape literal text re.escape(s) RegExp.escape(s) (Node.js 24+) preg_quote(s, '/')

The examples on this page run on Python 3.11, Node.js 24 and PHP 8.2 (whose preg functions use the PCRE2 library, version 10.42 here). They work with one line from Kestrel Energy's smart-meter log and a few ticket references. Replacing text is covered in more depth on Search and Replace, and flags such as case-insensitive matching on Flags and Modes.

Python: the re module

The re module is Python's standard-library module for regular expressions: functions such as re.search, re.findall and re.sub take a pattern as a string, and re.compile turns a pattern into a reusable object with the same methods. Write patterns as raw strings (r"…"): in a normal string Python itself interprets backslashes first, and "\b" becomes a backspace character before the regex engine ever sees it.

import re

text = "Escalated INC-31902 P2"
print(re.search("\bP2\b", text))     # "\b" is a backspace here
print(re.search(r"\bP2\b", text))    # r"\b" is a word boundary
None
<re.Match object; span=(20, 22), match='P2'>

search, match, fullmatch and match objects

Python has three ways to look for one match. re.search scans the whole string, re.match only tries at the very start, and re.fullmatch requires the whole string to match. Each returns None when there is no match, or a match object: an object that describes one successful match, with the matched text, the text of each capture group and their positions in the input.

import re

line = "2026-10-02 08:00 MTR-100231 1542.7 kWh"
m = re.search(r"MTR-(\d{6}) ([0-9.]+)", line)
print(m)
print(m.group(0), "|", m.group(1), "|", m.group(2))
print(m.groups())
print(m.start(), m.end(), m.span(2))

print(re.match(r"MTR", line))                       # not at the start
print(re.fullmatch(r"MTR-\d{6}", "MTR-100231"))
print(re.fullmatch(r"MTR-\d{6}", "MTR-1002315"))
<re.Match object; span=(17, 34), match='MTR-100231 1542.7'>
MTR-100231 1542.7 | 100231 | 1542.7
('100231', '1542.7')
17 34 (28, 34)
None
<re.Match object; span=(0, 10), match='MTR-100231'>
None

Because a failed search returns None, calling .group() on it raises AttributeError. Test first: if m := re.search(…): or if m is not None:.

findall, finditer and the group trap

re.findall returns a list of all matches, but what goes into the list depends on the groups in the pattern: no groups gives the matched strings, one group gives that group's text, and several groups give tuples. Adding parentheses for alternation can therefore silently change the result:

import re

text = "INC-31902 P2, INC-31917 P3, CHG-4471"
print(re.findall(r"INC-\d+", text))
print(re.findall(r"(INC|CHG)-\d+", text))       # one group: only the group!
print(re.findall(r"(?:INC|CHG)-\d+", text))     # non-capturing: whole matches
print(re.findall(r"(INC)-(\d+) (P\d)", text))   # several groups: tuples

for m in re.finditer(r"(?P<id>INC-\d+) (?P<prio>P\d)", text):
    print(m["id"], m["prio"], m.span())
['INC-31902', 'INC-31917']
['INC', 'INC', 'CHG']
['INC-31902', 'INC-31917', 'CHG-4471']
[('INC', '31902', 'P2'), ('INC', '31917', 'P3')]
INC-31902 P2 (0, 12)
INC-31917 P3 (14, 26)

re.finditer yields match objects, so you get the whole match, every group by number or name, and the positions. It is the clearest choice as soon as a pattern has groups. Note Python's named-group syntax, (?P<name>…); see Groups, Captures and Backreferences.

Compiling, replacing and splitting

import re

METER = re.compile(r"MTR-(\d{2})\d{4}")      # compile once, use often
note = "Swap MTR-100231 and MTR-100232; check MTR-990001"

print(METER.findall(note))
print(METER.sub(r"MTR-\1xxxx", note))                    # \1 = group 1
print(METER.sub(lambda m: m.group(0).lower(), note))     # a function
print(re.split(r"[;,]\s*", "web01, web02;db01,  mail"))
['10', '10', '99']
Swap MTR-10xxxx and MTR-10xxxx; check MTR-99xxxx
Swap mtr-100231 and mtr-100232; check mtr-990001
['web01', 'web02', 'db01', 'mail']

Python caches recently used patterns, so calling re.search(r"…", s) in a loop does not recompile the pattern each time. re.compile is mostly about readability: the pattern gets a name and is defined in one place.

JavaScript: RegExp objects and string methods

In JavaScript a regular expression is a RegExp object: an object that holds a compiled pattern, its flags and, for global and sticky patterns, a lastIndex position. You create one with a literal, /MTR-(\d{6})/, or with the constructor, new RegExp("MTR-(\\d{6})"), when the pattern is built at run time. Its own methods are test and exec; the string methods match, matchAll, replace, replaceAll, search and split accept one too.

const line = "2026-10-02 08:00 MTR-100231 1542.7 kWh";
const reading = /MTR-(\d{6}) ([0-9.]+)/;

console.log(reading.test(line));
const m = reading.exec(line);
console.log(m[0], "|", m[1], "|", m[2], "| index", m.index);
console.log(line.match(/\d+/g).join(" "));   // with g: just the strings

// From a string, every backslash must be doubled:
console.log(new RegExp("MTR-(\\d{6})").test(line));
console.log(new RegExp("MTR-(\d{6})").source);   // "\d" became "d"
true
MTR-100231 1542.7 | 100231 | 1542.7 | index 17
2026 10 02 08 00 100231 1542 7
true
MTR-(d{6})

The last line shows the constructor trap: in a normal JavaScript string "\d" is just d, so the pattern silently changes. A template literal with String.raw`MTR-(\d{6})` avoids the doubling; see tagged templates.

matchAll, replace and split

const text = "INC-31902 P2, INC-31917 P3, CHG-4471";

for (const m of text.matchAll(/(?<id>INC-\d+) (?<prio>P\d)/g)) {
  console.log(m.groups.id, m.groups.prio, m.index);
}
console.log(text.replace(/INC-(\d+)/, "incident $1"));     // first only
console.log(text.replace(/INC-(\d+)/g, "incident $1"));    // all
console.log(text.replace(/(?<id>CHG-\d+)/, "[$<id>]"));
console.log(text.replace(/\d+/g, (d) => d.length));       // a function
console.log(text.split(/,\s*/));

// A plain string is not a pattern:
console.log("10.0.0.1".replace(".", "-"), "10.0.0.1".replaceAll(".", "-"));
INC-31902 P2 0
INC-31917 P3 14
incident 31902 P2, INC-31917 P3, CHG-4471
incident 31902 P2, incident 31917 P3, CHG-4471
INC-31902 P2, INC-31917 P3, [CHG-4471]
INC-5 P1, INC-5 P1, CHG-4
[ 'INC-31902 P2', 'INC-31917 P3', 'CHG-4471' ]
10-0.0.1 10-0-0-1

Without the g flag, replace changes only the first match. Replacement strings use $1, $<name> and $& (the whole match). Python uses \1 and \g<name>; PHP accepts $1 and $0 but not $<name> or $&. When the first argument is a string rather than a regex, it is taken literally: the dots in the last line are dots, not "any character".

The lastIndex trap

A RegExp with the g (global) or y (sticky) flag stores where its last match ended in lastIndex, and the next test or exec starts there. That is what makes a while (m = re.exec(s)) loop work, but it breaks a shared validation pattern:

const incidentId = /^INC-\d{5}$/g;   // the g flag is the bug
const ids = ["INC-31902", "INC-31917", "INC-31920"];

console.log(ids.map((id) => incidentId.test(id)));
console.log("lastIndex now:", incidentId.lastIndex);

const fixed = /^INC-\d{5}$/;         // no g: no state
console.log(ids.map((id) => fixed.test(id)));
[ true, false, true ]
lastIndex now: 9
[ true, true, true ]

The second ID is valid, yet the test fails: it started at position 9, the end of the previous match, found nothing and reset lastIndex to 0, so the third test works again. Leave out g unless you really iterate. The opposite mistake raises an error: matchAll and replaceAll with a regex require g.

const text = "INC-31902 P2, INC-31917 P3";
for (const m of text.matchAll(/INC-\d+/)) {
  console.log(m[0]);
}
TypeError: String.prototype.matchAll called with a non-global RegExp argument

PHP: the preg functions

The preg functions are PHP's built-in functions for Perl-compatible regular expressions, such as preg_match, preg_match_all, preg_replace and preg_split, all backed by the PCRE2 library. The pattern is an ordinary PHP string that includes delimiters and, after the closing one, optional modifiers such as i or u. Single-quoted PHP strings keep backslashes as they are, so '/\d+/' needs no doubling.

preg_match returns 1 when the pattern matches, 0 when it does not, and false when something went wrong. The third argument receives the match and its groups:

<?php
$line = "2026-10-02 08:00 MTR-100231 1542.7 kWh";

var_dump(preg_match('/MTR-(\d{6}) ([0-9.]+)/', $line, $m));
print_r($m);
var_dump(preg_match('/MTR-\d{7}/', $line));

if (preg_match('/kWh$/', $line) === 1) {
    echo "unit is kWh\n";
}
int(1)
Array
(
    [0] => MTR-100231 1542.7
    [1] => 100231
    [2] => 1542.7
)
int(0)
unit is kWh

Write === 1 (or === 0) instead of a plain if (preg_match(…)). Both 0 and false are falsy, so a loose check cannot tell "no match" from "the pattern is broken" or "the input is too complex", which is how errors go unnoticed. PHP's manual also advises str_contains() or strpos() when you only need to know whether a fixed string occurs: they are faster.

Delimiters and modifiers

A pattern delimiter is the character that marks where a regular expression starts and ends inside a string, as in '/MTR-\d+/i', with modifiers after the closing delimiter. PHP inherited the idea from Perl and JavaScript literals use slashes in the same way. The slash is customary, but any single-byte non-alphanumeric character other than backslash and whitespace works, as do bracket pairs. Picking another delimiter saves escaping when the pattern contains slashes:

<?php
$path = '/srv/portal/export/2026-10.csv';

var_dump(preg_match('/^\/srv\/portal\//', $path));   // escaped slashes
var_dump(preg_match('#^/srv/portal/#', $path));      // # as delimiter
var_dump(preg_match('{EXPORT}i', $path));            // braces, i = ignore case

// Literal text from a variable: escape it, including the delimiter
$name = '2026-10.csv';
echo preg_quote($name, '#'), "\n";
var_dump(preg_match('#/' . preg_quote($name, '#') . '$#', $path));
int(1)
int(1)
int(1)
2026\-10\.csv
int(1)

preg_match_all, replace and split

<?php
$text = "INC-31902 P2, INC-31917 P3, CHG-4471";

echo preg_match_all('/(?<id>INC-\d+) (?<prio>P\d)/', $text, $m), "\n";
echo implode(' ', $m['id']), ' | ', implode(' ', $m[2]), "\n";

preg_match_all('/(INC-\d+) (P\d)/', $text, $sets, PREG_SET_ORDER);
foreach ($sets as $s) {
    echo "$s[1] has priority $s[2]\n";
}

echo preg_replace('/INC-(\d+)/', 'incident $1', $text), "\n";
echo preg_replace_callback('/\d+/', fn($d) => strlen($d[0]), $text), "\n";
print_r(preg_split('/,\s*/', $text));
2
INC-31902 INC-31917 | P2 P3
INC-31902 has priority P2
INC-31917 has priority P3
incident 31902 P2, incident 31917 P3, CHG-4471
INC-5 P1, INC-5 P1, CHG-4
Array
(
    [0] => INC-31902 P2
    [1] => INC-31917 P3
    [2] => CHG-4471
)

By default preg_match_all groups the results per group (all whole matches, then all first groups, and so on); PREG_SET_ORDER gives one array per match, which is usually easier to loop over. Named groups appear in $m twice, under their name and their number. preg_replace replaces every match by default, unlike JavaScript's replace and like Python's re.sub.

One task in three languages

A meter gateway reports readings as ID=value pairs separated by semicolons; a failed meter reports ERR. The task: list the valid readings and their total. The pattern is the same idea everywhere, with an anchored, named group for the ID and a strict number format for the value. Only the named-group syntax differs: (?P<id>…) in Python, (?<id>…) in JavaScript and PHP (PHP accepts both).

import re

report = "MTR-100231=1542.7;MTR-100232=ERR;MTR-100233=877;MTR-10023=12"
pair = re.compile(r"(?P<id>MTR-[0-9]{6})=(?P<kwh>[0-9]+(?:\.[0-9]+)?)")

total = 0.0
for item in report.split(";"):
    m = pair.fullmatch(item)
    if m is None:
        print("skipped:", item)
        continue
    total += float(m["kwh"])
    print(m["id"], m["kwh"])
print(f"total: {total:.1f} kWh")
MTR-100231 1542.7
skipped: MTR-100232=ERR
MTR-100233 877
skipped: MTR-10023=12
total: 2419.7 kWh
const report = "MTR-100231=1542.7;MTR-100232=ERR;MTR-100233=877;MTR-10023=12";
const pair = /^(?<id>MTR-[0-9]{6})=(?<kwh>[0-9]+(?:\.[0-9]+)?)$/;

let total = 0;
for (const item of report.split(";")) {
  const m = pair.exec(item);
  if (m === null) {
    console.log("skipped:", item);
    continue;
  }
  total += Number(m.groups.kwh);
  console.log(m.groups.id, m.groups.kwh);
}
console.log(`total: ${total.toFixed(1)} kWh`);
MTR-100231 1542.7
skipped: MTR-100232=ERR
MTR-100233 877
skipped: MTR-10023=12
total: 2419.7 kWh
<?php
$report = "MTR-100231=1542.7;MTR-100232=ERR;MTR-100233=877;MTR-10023=12";
$pair = '/^(?<id>MTR-[0-9]{6})=(?<kwh>[0-9]+(?:\.[0-9]+)?)$/D';

$total = 0.0;
foreach (explode(';', $report) as $item) {
    if (preg_match($pair, $item, $m) !== 1) {
        echo "skipped: $item\n";
        continue;
    }
    $total += (float) $m['kwh'];
    echo $m['id'], ' ', $m['kwh'], "\n";
}
printf("total: %.1f kWh\n", $total);
MTR-100231 1542.7
skipped: MTR-100232=ERR
MTR-100233 877
skipped: MTR-10023=12
total: 2419.7 kWh

The three programs print the same thing. Python uses fullmatch; JavaScript and PHP anchor the pattern with ^ and $, and PHP adds the D modifier for a reason explained in the next section. [0-9] is used instead of \d on purpose: it means the same in all three.

Differences that bite

Python's re, JavaScript and PCRE2 are all backtracking engines with Perl-style syntax, so most patterns behave the same. These are the differences that most often cause real bugs:

Feature Python 3.11 re JavaScript (Node.js 24) PHP 8.2 (PCRE2 10.42)
\d, \w Unicode (any script's digits and letters) ASCII only, even with u ASCII; Unicode with the u modifier
$ at the end Also matches before a final \n Only at the very end Also before a final \n, unless D
Named group (?P<n>…) only (?<n>…) both
Group in replacement \1, \g<1>, \g<name> $1, $<name> $1, \1, ${1}; no names
Replace all by default yes (re.sub) no (needs g) yes (preg_replace)
Possessive a++, atomic (?>…) yes (since 3.11) no yes
Lookbehind fixed length only any length alternatives of different fixed lengths
Unicode properties \p{L} no (third-party regex module) with u or v yes (use u for UTF-8 text)

\d is not the same everywhere

A meter label typed on a phone with an Arabic keyboard can contain Arabic-Indic digits (٣٤ is 34). The same pattern finds different numbers:

import re

label = "Meter ٣٤ reading 42"
print(re.findall(r"\d+", label))
print(re.findall(r"\d+", label, flags=re.ASCII))
print(re.findall(r"[0-9]+", label))
['٣٤', '42']
['42']
['42']
const label = "Meter ٣٤ reading 42";
console.log(label.match(/\d+/g));
console.log(label.match(/\p{Nd}+/gu));
[ '42' ]
[ '٣٤', '42' ]
<?php
$label = "Meter ٣٤ reading 42";
preg_match_all('/\d+/', $label, $plain);
preg_match_all('/\d+/u', $label, $unicode);
echo implode(', ', $plain[0]), "\n";
echo implode(', ', $unicode[0]), "\n";
42
٣٤, 42

Python's default is the surprising one: \d accepts digits that int() can convert but your database or the next system may not. When a field must contain ASCII digits, write [0-9] or use re.ASCII. See Character Classes and Shorthands.

$ and a trailing newline

In Python and PCRE, $ matches at the end of the text and just before a newline at the very end. A validation pattern ^INC-\d{5}$ therefore accepts "INC-31902\n", a value with a hidden extra character that may break whatever stores or compares it later:

import re

value = "INC-31902\n"
print(bool(re.search(r"^INC-\d{5}$", value)))
print(bool(re.fullmatch(r"INC-\d{5}", value)))
print(bool(re.search(r"^INC-\d{5}\Z", value)))
True
False
False
<?php
$value = "INC-31902\n";
var_dump(preg_match('/^INC-\d{5}$/', $value));
var_dump(preg_match('/^INC-\d{5}$/D', $value));
var_dump(preg_match('/^INC-\d{5}\z/', $value));
int(1)
int(0)
int(0)

JavaScript's /^INC-\d{5}$/ rejects the value, because there $ only means the end of the string (unless the m flag is on). In Python, prefer re.fullmatch; \Z is Python's "absolute end", which PCRE writes as \z (Python added \z as an alias in 3.14). In PHP, use the D modifier or \z. The Anchors and Word Boundaries page covers the details.

Escaping text you did not write

When part of a pattern comes from a user, a configuration file or a database, escape it so that its dots, brackets and plus signs are matched literally. Otherwise a search for 1.5+ becomes a pattern, and text such as (a+)+ could make the engine work for a very long time (see Performance and Catastrophic Backtracking). Python has re.escape and PHP has preg_quote (pass the delimiter as the second argument):

import re

search = "price 1.5+ (incl. VAT)"
print(re.escape(search))
text = "Tariff: price 1.5+ (incl. VAT) per kWh"
print(bool(re.search(re.escape(search), text)))
print(bool(re.search(search, text)))     # unescaped: wrong meaning
price\ 1\.5\+\ \(incl\.\ VAT\)
True
False

JavaScript gained RegExp.escape in ECMAScript 2025; it is available from Node.js 24 and in current browsers (as of 2026), but not in Node.js 20. For older runtimes, the usual helper escapes every character that has a meaning in a pattern:

function escapeRegExp(text) {
  return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}

const search = "price 1.5+ (incl. VAT)";
console.log(escapeRegExp(search));
const text = "Tariff: price 1.5+ (incl. VAT) per kWh";
console.log(new RegExp(escapeRegExp(search)).test(text));
console.log(typeof RegExp.escape);     // "function" from Node.js 24
price 1\.5\+ \(incl\. VAT\)
true
function
// Node.js 24+ and current browsers:
const pattern = new RegExp(RegExp.escape(search));

When the pattern itself is wrong

A pattern with a missing parenthesis is a programming error, and each language reports it differently. Python raises re.error with the position of the problem:

import re

re.compile(r"MTR-(\d{6}")
re.error: missing ), unterminated subpattern at position 4

JavaScript throws a SyntaxError: for a literal when the script is loaded, for new RegExp when that line runs, so a pattern built from input needs a try/catch:

try {
  new RegExp("MTR-(\\d{6}");
} catch (error) {
  console.log(error.name + ": " + error.message);
}
SyntaxError: Invalid regular expression: /MTR-(\d{6}/: Unterminated group

PHP does not throw. The preg function emits a warning and returns false, and the program continues; that is why the === 1 comparison matters. The warning text is available through error_get_last(); for limits hit at run time, such as the backtrack limit, preg_last_error_msg() gives a readable reason.

<?php
$result = preg_match('/MTR-(\d{6}/', 'MTR-100231');
var_dump($result);
echo error_get_last()['message'], "\n";

$result = preg_match('/(a+)+$/', str_repeat('a', 30) . 'b');
var_dump($result);
echo preg_last_error_msg(), "\n";
bool(false)
preg_match(): Compilation failed: missing closing parenthesis at offset 10
bool(false)
Backtrack limit exhausted

The second call is a classic catastrophic-backtracking pattern. PCRE2 gives up after its backtrack limit (PHP's pcre.backtrack_limit, 1,000,000 by default) and returns false; code that treats false as "no match" would wrongly accept or reject the input. Python and JavaScript have no such limit: the same pattern on a long input simply runs for a very long time.