Testing, Debugging and Readability
Keeping regular expressions correct and maintainable: building patterns step by step, test cases for matches and non-matches, verbose mode and comments, online testers, and checking AI-written patterns.
Build a pattern step by step
Regular expressions are dense: one wrong character changes the meaning, and a long pattern that "doesn't work" gives no hint about where it goes wrong. So write them the way you would debug them: start with a small piece that surely matches, run it against real lines, and add one part at a time.
Say the NOC wants the meter ID and the reading from smart-meter gateway logs. Each step below is run against three real lines, including a broken one:
import re
lines = [
"2026-10-02T08:14:03Z gw03 READING meter=KE-0042 kwh=318.4 status=OK",
"2026-10-02T08:14:05Z gw03 READING meter=KE-0107 kwh=12 status=OK",
"2026-10-02T08:14:09Z gw07 READING meter=KE-00421 kwh=-1 status=ERR",
]
steps = [
r"meter=",
r"meter=KE-[0-9]{4}",
r"meter=KE-[0-9]{4}\b",
r"meter=(KE-[0-9]{4})\b kwh=([0-9]+(?:\.[0-9]+)?)\b",
]
for step in steps:
print(step)
for line in lines:
m = re.search(step, line)
print(" ", m.groups() if m and m.groups() else (m.group() if m else None))
meter=
meter=
meter=
meter=
meter=KE-[0-9]{4}
meter=KE-0042
meter=KE-0107
meter=KE-0042
meter=KE-[0-9]{4}\b
meter=KE-0042
meter=KE-0107
None
meter=(KE-[0-9]{4})\b kwh=([0-9]+(?:\.[0-9]+)?)\b
('KE-0042', '318.4')
('KE-0107', '12')
None
Step 2 reveals a bug that a finished pattern would have hidden: the
third line has a malformed ID, KE-00421, and the
pattern happily reports it as KE-0042, a different
meter. That is
overmatching
in its most dangerous form: a wrong answer that looks right. The
word boundary
\b in step 3 fixes it. Without the step-by-step run,
nobody would have noticed until a customer got the wrong bill.
Test cases
A regex test case is an input string paired with the result the pattern should give for it: match or no match, and, where it matters, which text or groups it should capture. A list of test cases turns "I think it works" into something you can run again after every change, like any other automated test. A library can also generate inputs for you; see Property-Based Testing.
Positive and negative examples
Good test lists have two halves.
Positive and negative examples
are the inputs a pattern must match (positive) and the inputs it
must reject (negative). Most people write only the positive half,
and a pattern like .* passes every positive test. The
negative half is where the bugs are. Good negative examples are
near misses: one character too many or too few, the wrong
case, a trailing space, an empty string, digits from another script.
Here is a check for a Kestrel Energy incident number field, tested against both halves:
import re
SHOULD_MATCH = ["INC-31902", "INC-00017"]
SHOULD_NOT_MATCH = ["INC-3190", "INC-319021", "inc-31902", "CHG-1234",
"INC-31902 ", "INC-٣١٩٠٢", ""]
def run_tests(name, is_ticket):
failures = [v for v in SHOULD_MATCH if not is_ticket(v)]
failures += [v for v in SHOULD_NOT_MATCH if is_ticket(v)]
total = len(SHOULD_MATCH) + len(SHOULD_NOT_MATCH)
print(f"{name}: {total - len(failures)}/{total} passed", failures)
run_tests("first try", lambda v: re.search(r"INC-\d{5}", v) is not None)
run_tests("fixed ", lambda v: re.fullmatch(r"INC-[0-9]{5}", v) is not None)
first try: 6/9 passed ['INC-319021', 'INC-31902 ', 'INC-٣١٩٠٢'] fixed : 9/9 passed []
The first try passed both positive tests and would have looked fine
in a quick manual check. Three negative tests caught two separate
bugs: no anchoring (so INC-319021 and the trailing
space slip through) and \d accepting Arabic-Indic
digits in Python. The fix uses re.fullmatch() and
[0-9].
In a real project, put these lists in your test suite so they run on
every change; see
unit tests in Python. With unittest, subTest reports each
failing input separately:
import re
import unittest
TICKET = re.compile(r"INC-[0-9]{5}")
class TicketPatternTest(unittest.TestCase):
def test_accepts_valid_ids(self):
for value in ["INC-31902", "INC-00017"]:
with self.subTest(value=value):
self.assertIsNotNone(TICKET.fullmatch(value))
def test_rejects_near_misses(self):
for value in ["INC-3190", "INC-319021", "inc-31902", "INC-31902\n", ""]:
with self.subTest(value=value):
self.assertIsNone(TICKET.fullmatch(value))
if __name__ == "__main__":
unittest.main()
When a bug report comes in ("ticket INC-31902 was not
linked"), first add the input as a test case, watch it fail, then
fix the pattern. The test then stays as a
regression test.
Test in the flavor you deploy
A pattern is only tested for the engine you ran it in. The same text
^INC-\d{5}$ in three languages, with the same three
inputs:
import re
for value in ["INC-31902", "INC-31902\n", "INC-٣١٩٠٢"]:
print(repr(value), re.search(r"^INC-\d{5}$", value) is not None)
'INC-31902' True 'INC-31902\n' True 'INC-٣١٩٠٢' True
for (const value of ["INC-31902", "INC-31902\n", "INC-٣١٩٠٢"]) {
console.log(JSON.stringify(value), /^INC-\d{5}$/.test(value));
}
"INC-31902" true "INC-31902\n" false "INC-٣١٩٠٢" false
<?php
foreach (["INC-31902", "INC-31902\n", "INC-٣١٩٠٢"] as $value) {
echo json_encode($value, JSON_UNESCAPED_UNICODE), ' ',
preg_match('/^INC-\d{5}$/', $value), ' ',
preg_match('/^INC-\d{5}$/u', $value), "\n";
}
"INC-31902" 1 1 "INC-31902\n" 1 1 "INC-٣١٩٠٢" 0 1
Python accepts all three. JavaScript accepts only the first: its
$ does not match before a final newline and its
\d is ASCII-only. PHP accepts the newline, and accepts
the Arabic-Indic digits only with the u modifier. A
pattern copied from a JavaScript front end to a Python back end
therefore needs its tests run again in Python. The differences are
collected on regex flavors.
Debugging a pattern
See exactly what matched
"It matches" is not enough information. Print the matched text and
its position, every time, until the pattern behaves. The helper
below marks each match under the text, which makes an overmatching
.* obvious at a glance:
import re
def show(pattern, text):
"""Print the text with ^ under every character the pattern matched."""
print(pattern)
print(" " + text)
marks = [" "] * len(text)
for m in re.finditer(pattern, text):
start, end = m.span()
marks[start:end] = "^" * (end - start)
print(" " + "".join(marks).rstrip())
line = 'login user="amara" host="portal01" result="ok"'
show(r'"(.*)"', line)
show(r'"([^"]*)"', line)
"(.*)"
login user="amara" host="portal01" result="ok"
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
"([^"]*)"
login user="amara" host="portal01" result="ok"
^^^^^^^ ^^^^^^^^^^ ^^^^
The greedy .* runs from the first quote to the last one
(see
quantifiers); [^"]* cannot cross a quote, so it stops at the
right place. On the command line, grep -o does the same
job: it prints only the matched part of each line.
Make invisible characters visible
When a pattern that "obviously" fits does not match, the input often
contains something you cannot see: a trailing space, a tab, a
non-breaking space, or the carriage return (CR) that Windows tools
put before every line feed (LF). GNU sed's l
command prints lines with such characters spelled out:
# An export from a Windows tool: lines end in CR LF
printf 'INC-31902\r\nINC-31903\r\nINC-31904\n' > tickets.txt
grep -cE '^INC-[0-9]{5}$' tickets.txt # expected 3
# Make the invisible visible: sed's l command shows \r and ends lines with $
sed -n 'l' tickets.txt
# Fix the input, not the pattern
tr -d '\r' < tickets.txt | grep -cE '^INC-[0-9]{5}$'
1 INC-31902\r$ INC-31903\r$ INC-31904$ 3
cat -A and Python's repr() show the same
information. In Python, re.compile(pattern, re.DEBUG)
goes one step further and prints how the engine parsed the pattern.
The exact format differs between Python versions, but it can settle
an argument quickly; in this excerpt from Python 3.11,
UNI_DIGIT shows that \d means "any Unicode
digit":
SUBPATTERN 1 0 0
MAX_REPEAT 1 MAXREPEAT
IN
CATEGORY CATEGORY_DIGIT
…
13. CATEGORY UNI_DIGIT
Common bugs and their fixes
| Symptom | Likely cause | Fix |
|---|---|---|
| Accepts input with extra text around it |
No anchors, or search instead of a full match
|
re.fullmatch(), or ^…\z /
\Z per flavor
|
Pattern 192.0.2.1 also matches
192a0b2c1
|
Unescaped dot matches any character | \. |
| Captures far too much | Greedy .* runs to the last delimiter |
[^"]* or a lazy .*? |
\b never matches in Python |
Normal string: "\b" is a backspace character
|
Raw string r"\b" |
\d matches a literal d in grep
|
\d is not a digit class in POSIX grep |
[0-9], or grep -P |
| Works in the tester, fails in code | Different flavor, flags or escaping in the string | Pick the right flavor; test in the real language |
| Hangs on some inputs | Nested or overlapping quantifiers | See catastrophic backtracking |
The raw-string row is explained on Python strings and literals, metacharacters and escaping.
Readable patterns
A pattern is read far more often than it is written, usually by someone in a hurry during an incident. A one-line pattern of 80 characters is hard to review and easy to break. Three tools help: verbose mode, named groups and building a pattern from parts.
Verbose mode and comments
Verbose mode (Python's
re.VERBOSE, PCRE's x modifier) ignores
whitespace in the pattern, so you can spread it over several lines
and indent it. It also allows comments. A
pattern comment is text
inside a regular expression that the engine ignores: in verbose mode
everything from # to the end of the line, and in Python
and PCRE also an inline (?#…) group that works without
verbose mode. Combined with
named groups,
the meter-reading pattern becomes self-documenting:
import re
READING = re.compile(r"""
^(?P<time> [0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9:]{8}Z ) \s+ # ISO 8601, UTC
(?P<gateway> gw[0-9]{2} ) \s+ # gateway name
READING \s+
meter=(?P<meter> KE-[0-9]{4} ) \b \s+ # 4 digits, no more
kwh=(?P<kwh> [0-9]+ (?:\.[0-9]+)? ) \b # 12 or 318.4
""", re.VERBOSE)
line = "2026-10-02T08:14:03Z gw03 READING meter=KE-0042 kwh=318.4 status=OK"
m = READING.search(line)
print(m.groupdict())
{'time': '2026-10-02T08:14:03Z', 'gateway': 'gw03', 'meter': 'KE-0042', 'kwh': '318.4'}
In verbose mode a literal space or # must be escaped
(\ , \#) or written as a class ([ ]), because unescaped ones are ignored. PHP does the same with the
x modifier. Note that PHP names groups with
(?<name>…), a form Python 3.11 rejects:
<?php
$reading = '/
meter=(?<meter> KE-[0-9]{4} ) \b \s+ # meter ID, exactly 4 digits
kwh=(?<kwh> [0-9]+ (?:\.[0-9]+)? ) \b # whole or decimal number
/x';
$line = '2026-10-02T08:14:03Z gw03 READING meter=KE-0042 kwh=318.4 status=OK';
preg_match($reading, $line, $m);
echo $m['meter'], ' ', $m['kwh'], "\n";
// (?#...) is an inline comment, without the x modifier
echo preg_match('/INC-(?#incident)[0-9]{5}/', 'INC-31902'), "\n";
KE-0042 318.4 1
Building a pattern from named parts
JavaScript has no verbose flag and no (?#…) comments
(both are errors in Node.js 24). The usual alternative is to build
the pattern from small strings with ordinary code comments and join
them. String.raw keeps the backslashes as typed:
// JavaScript has no x flag: build the pattern from named, commented parts.
const parts = [
String.raw`meter=(?<meter>KE-[0-9]{4})\b`, // meter ID, exactly 4 digits
String.raw`\s+`,
String.raw`kwh=(?<kwh>[0-9]+(?:\.[0-9]+)?)\b`, // whole or decimal number
];
const reading = new RegExp(parts.join(""));
const line = "2026-10-02T08:14:03Z gw03 READING meter=KE-0042 kwh=318.4 status=OK";
console.log(reading.source);
console.log(line.match(reading).groups);
meter=(?<meter>KE-[0-9]{4})\b\s+kwh=(?<kwh>[0-9]+(?:\.[0-9]+)?)\b
[Object: null prototype] { meter: 'KE-0042', kwh: '318.4' }
The same idea works in every language: give reusable pieces a name
(METER_ID, DECIMAL) and compose them. Only
insert fixed pieces this way; text from users must be
escaped first, or it becomes part of the pattern.
Online regex testers
A regex tester is a tool, usually a web page, where you type a pattern and sample text and see the matches, groups and an explanation of each part update as you type. They are excellent for learning and for the step-by-step building shown above. Two widely used ones:
- regex101.com lets you choose the flavor. As of 2026 it offers PCRE2 (PHP), JavaScript, Python, Golang, Java, .NET, Rust, POSIX ERE and BRE, and legacy PCRE. It explains every token in plain language.
- RegExr (regexr.com) focuses on the JavaScript and PCRE flavors and has a reference panel and community patterns.
Two cautions. First, always select the flavor that matches where the
pattern will run, and still run your tests in the real language:
escaping inside a string literal, flags and API differences (re.match()
versus re.search()) are invisible in a tester. Second,
whatever you paste into a website leaves your control. Use invented
sample lines, never production logs with customer data, email
addresses, tokens or passwords; for real data, test locally with
grep or a short script.
This topic has its own tester: the Regex Tester runs in your browser with the JavaScript engine, sends nothing anywhere and stops patterns that run too long.
Checking AI-written patterns
AI assistants write regular expressions quickly and often well. They also produce patterns for the wrong flavor, with missing anchors, or with nested quantifiers, delivered with the same confidence as correct ones. Treat a pattern from an AI assistant exactly like one from a forum or a colleague's old script: as code from a stranger that you test before you trust it (see checking AI output). A short checklist:
-
Flavor: was it written for the engine you use?
(?<name>…)fails in Python 3.11,\dmeans something else in grep, and possessive quantifiers fail in JavaScript. - Positive and negative examples: run it on your own test list, especially near misses, empty input, very long input and non-ASCII text.
- Anchoring: does it check the whole value when it should?
- Backtracking risk: look for nested or overlapping quantifiers and test with a long failing input (see performance).
- Understanding: can you explain every part? If not, ask for the verbose version with comments, or simplify it. You will be the one debugging it at 3 a.m.
Asking the assistant to also generate test cases is useful, but add your own negative examples: a model that misunderstood the requirement will write tests that share the misunderstanding.
Keeping patterns maintainable
- Keep the examples next to the pattern. A comment with one matching and one non-matching example tells the next reader more than a description.
-
Give compiled patterns names
(
TICKET_ID,METER_READING) and define them once, not copied into five places. - Run the tests automatically on every change, so a "small improvement" to a pattern cannot silently break a case someone fixed last year.
- Prefer the simpler pattern when two are equivalent. Clever patterns save a line and cost an afternoon.
- Document the flavor when a pattern is stored outside code (a config file, a monitoring rule, a firewall rule): the next system that reads it may use a different engine.