Regex in Python, JavaScript and PHP
The same tasks in three languages: finding, extracting, replacing and splitting with Python's re module, JavaScript's RegExp and PHP's preg functions, and their traps.
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.