What a regex can check

Input validation checks that incoming data has the expected type, length, format and range before an application uses it. Regular expressions handle one part of that job: format validation, checking that a value has the right shape: four digits and two letters, an @ with something on both sides, a plus sign followed by digits.

A shape is not the truth. A pattern cannot tell whether a mailbox exists, whether a phone number belongs to the person typing it, or whether 2026-02-30 is a real day. So a regex is usually the first, cheap layer of a check, not the whole check:

Five layers of input validation from left to right: normalize the value, limit its length, check the format with a regex, check the meaning with a parser or a list, and confirm with the owner. Each layer can reject the input. Normalize trim, case Length max chars Format regex Meaning parser, list Confirm email, SMS any layer can reject the input with a clear message
The regex is one layer in the middle. Cheap checks come first; the only check that proves an email address or phone number belongs to someone is the last one.

Two security rules from web application security shape everything below. First, describe what is allowed (an allowlist) instead of trying to list what is dangerous. Second, validation that counts happens on the server. Validation does not replace output encoding or parameterized queries; a valid-looking value can still be hostile in another context.

Match the whole value

The most common validation bug is a pattern that finds a valid part of the input and accepts the rest along with it. Searching is the default in most APIs: Python's re.search(), JavaScript's test() and PHP's preg_match() all succeed if the pattern matches anywhere. Take a Dutch postal code, four digits (never starting with 0) and two letters, like 2511 CV:

import re

postcode = r"[1-9][0-9]{3} ?[A-Z]{2}"
values = ["2511 CV", "2511CV", "0123 AB", "2511 CV; DROP TABLE", "x2511 CV"]

for value in values:
    found = re.search(postcode, value) is not None
    whole = re.fullmatch(postcode, value) is not None
    print(f"{value!r:23} search: {found!s:5}  fullmatch: {whole}")
'2511 CV'               search: True   fullmatch: True
'2511CV'                search: True   fullmatch: True
'0123 AB'               search: False  fullmatch: False
'2511 CV; DROP TABLE'   search: True   fullmatch: False
'x2511 CV'              search: True   fullmatch: False

re.fullmatch() succeeds only if the pattern covers the entire string (a full match). In other languages you add the anchors ^ and $ yourself, and there is a trap: $ does not mean "end of string" everywhere.

The trailing-newline trap

In Python and PHP (PCRE), $ also matches just before a newline at the very end of the string. A value read from a file or sent by a script often ends with one. Run the same anchored pattern in three languages:

import re

value = "2511 CV\n"   # a trailing newline, e.g. from a file or an API
print(re.search(r"^[1-9][0-9]{3} ?[A-Z]{2}$", value) is not None)
print(re.search(r"^[1-9][0-9]{3} ?[A-Z]{2}\Z", value) is not None)
print(re.fullmatch(r"[1-9][0-9]{3} ?[A-Z]{2}", value) is not None)
True
False
False
const value = "2511 CV\n";
console.log(/^[1-9][0-9]{3} ?[A-Z]{2}$/.test(value));
console.log(/^[1-9][0-9]{3} ?[A-Z]{2}$/m.test(value));
false
true
<?php
$value = "2511 CV\n";
var_dump(preg_match('/^[1-9][0-9]{3} ?[A-Z]{2}$/', $value));
var_dump(preg_match('/^[1-9][0-9]{3} ?[A-Z]{2}$/D', $value));
var_dump(preg_match('/^[1-9][0-9]{3} ?[A-Z]{2}\z/', $value));
int(1)
int(0)
int(0)

Python and PHP accept the value with its newline; JavaScript rejects it (unless the m flag is on, which makes $ match at every line end). The robust choices are re.fullmatch() or \Z in Python (in Python, \Z means the absolute end), and \z or the D modifier in PHP. Never turn on multiline mode in a validation pattern: it lets a valid first line carry anything on the lines after it.

Postal codes: normalize, then check

People type the same value in many ways: in lowercase, with or without a space, with a stray space at the end. Rejecting all of those annoys users and buys no safety. A better approach is to normalize first (trim spaces, fix the case), then validate strictly, then store one canonical form. The format itself differs per country:

A Dutch postal code is four digits (never starting with 0) and two letters, written as 2511 CV. People type it as 2511cv, 2511 CV or 2511 cv:

import re

POSTCODE = re.compile(r"([1-9][0-9]{3}) ?([A-Z]{2})")

def normalize_postcode(raw):
    """Return the postal code as '1234 AB', or None if it is not valid."""
    value = raw.strip().upper()
    if len(value) > 7:            # cheap length check before any regex
        return None
    match = POSTCODE.fullmatch(value)
    if match is None:
        return None
    return f"{match[1]} {match[2]}"

for raw in [" 2511cv ", "2511 CV", "2511  CV", "2511-CV", "251 CV", "٢٥١١ CV"]:
    print(f"{raw!r:12} -> {normalize_postcode(raw)}")
' 2511cv '   -> 2511 CV
'2511 CV'    -> 2511 CV
'2511  CV'   -> None
'2511-CV'    -> None
'251 CV'     -> None
'٢٥١١ CV'    -> None

The pattern uses [0-9] on purpose. In Python 3, \d matches any Unicode decimal digit, so \d{4} would accept the Arabic-Indic digits in the last line (see character classes and shorthands). For validation, say exactly which characters you allow.

The same check works on a whole file on the command line. Here grep -v inverts the match, so it prints the customer records whose postal code is not in the expected form, with line numbers (-n):

printf '%s\n' 'KE-0042;2511 CV' 'KE-0043;2511cv' 'KE-0044;0123 AB' \
  'KE-0045;3011 AH' 'KE-0046;3011 AH ' 'KE-0047;9711LM' > customers.csv

# Lines whose postal code (field 2) is NOT in the form 1234 AB or 1234AB
grep -nvE ';[1-9][0-9]{3} ?[A-Z]{2}$' customers.csv
echo "---"
grep -cE ';[1-9][0-9]{3} ?[A-Z]{2}$' customers.csv
2:KE-0043;2511cv
3:KE-0044;0123 AB
5:KE-0046;3011 AH
---
3

Line 5 looks fine on screen. It fails because of an invisible trailing space, which is exactly the kind of thing normalization should remove before the strict check.

A US ZIP code is five digits, such as 20500. The longer ZIP+4 form adds a hyphen and four digits that narrow the area down to a street segment or a building (the +4 parts in these examples are made up). ZIP codes in New England, New Jersey, Puerto Rico and the US Virgin Islands start with 0, and that leading zero is the classic bug: a spreadsheet or a numeric database column quietly turns 02134 into 2134. Store ZIP codes as text.

import re

ZIP = re.compile(r"([0-9]{5})(?:-?([0-9]{4}))?")

def normalize_zip(raw):
    """Return '12345' or '12345-6789', or None if it is not valid."""
    value = raw.strip()
    if len(value) > 10:           # cheap length check before any regex
        return None
    match = ZIP.fullmatch(value)
    if match is None:
        return None
    return match[1] if match[2] is None else f"{match[1]}-{match[2]}"

for raw in [" 20500 ", "20500-0001", "205000001", "2134", "20500 0001", "٢٠٥٠٠"]:
    print(f"{raw!r:13} -> {normalize_zip(raw)}")
' 20500 '     -> 20500
'20500-0001'  -> 20500-0001
'205000001'   -> 20500-0001
'2134'        -> None
'20500 0001'  -> None
'٢٠٥٠٠'       -> None

The pattern uses [0-9] on purpose. In Python 3, \d matches any Unicode decimal digit, so \d{5} would accept the Arabic-Indic digits in the last line (see character classes and shorthands). And 2134 is rejected rather than "repaired": it may be a ZIP code that lost its leading zero, but the code cannot know that, so the user is asked again.

The same check works on a whole file on the command line. Here grep -v inverts the match, so it prints the order records whose ZIP code is not in the expected form, with line numbers (-n):

printf '%s\n' 'A-1001;20500' 'A-1002;2134' 'A-1003;20500-0001' \
  'A-1004;20500 ' 'A-1005;02134' 'A-1006;205000001' > orders.csv

# Lines whose ZIP code (field 2) is NOT in the form 12345 or 12345-6789
grep -nvE ';[0-9]{5}(-[0-9]{4})?$' orders.csv
echo "---"
grep -cE ';[0-9]{5}(-[0-9]{4})?$' orders.csv
2:A-1002;2134
4:A-1004;20500
6:A-1006;205000001
---
3

Line 4 looks fine on screen. It fails because of an invisible trailing space, and line 6 is a ZIP+4 without its hyphen: exactly the kind of thing normalization should fix before the strict check.

A UK postcode has two parts. The outward code (SW1A, M1, B33) names the postal area and district; the inward code (1AA, 1AE, 8TH) is always one digit and two letters and narrows it down to a street or a few addresses. Outward codes come in six shapes (A9, A99, AA9, AA99, A9A and AA9A, where A is a letter and 9 a digit), so the length varies, but the inward code is always the last three characters. That makes normalization easy: drop every space, then put one back before the last three. SW1A 1AA is Buckingham Palace; the other values are format examples.

import re

# Outward code: area (1-2 letters) + district (a digit, then maybe a digit or
# letter). Inward code: always one digit and two letters, the last 3 characters.
POSTCODE = re.compile(r"([A-Z]{1,2}[0-9][A-Z0-9]?)([0-9][A-Z]{2})")

def normalize_postcode(raw):
    """Return the postcode as 'SW1A 1AA', or None if the shape is wrong."""
    value = re.sub(r"\s+", "", raw).upper()     # drop every space first
    if value == "GIR0AA":                       # a historic exception
        return "GIR 0AA"
    if len(value) > 7:            # cheap length check before any regex
        return None
    match = POSTCODE.fullmatch(value)
    if match is None:
        return None
    return f"{match[1]} {match[2]}"

for raw in [" sw1a1aa ", "SW1A  1AA", "m11ae", "B338TH", "SW1A-1AA",
            "SW1A 1A", "gir 0aa", "SW١A ١AA"]:
    print(f"{raw!r:12} -> {normalize_postcode(raw)}")
' sw1a1aa '  -> SW1A 1AA
'SW1A  1AA'  -> SW1A 1AA
'm11ae'      -> M1 1AE
'B338TH'     -> B33 8TH
'SW1A-1AA'   -> None
'SW1A 1A'    -> None
'gir 0aa'    -> GIR 0AA
'SW١A ١AA'   -> None

The pattern checks the shape and nothing more, and it uses [0-9] on purpose: in Python 3, \d matches any Unicode decimal digit, so it would accept the Arabic-Indic digits in the last line (see character classes and shorthands). A stricter regex can encode more of Royal Mail's rules (some letters never appear in some positions), but only a lookup in Royal Mail's Postcode Address File (PAF) tells you whether a postcode exists, so real address forms look it up. One historic postcode, GIR 0AA, does not follow the current rules and is handled as an explicit exception. Long patterns also hide anchoring bugs: a pattern published in UK government guidance reads ^([Gg][Ii][Rr] 0[Aa]{2})|(…)$, so ^ anchors only the first alternative and $ only the second, and with a search function a normal postcode with text in front of it passes (see match the whole value).

The same check works on a whole file on the command line. Here grep -v inverts the match, so it prints the order records whose postcode is not in the canonical form, with line numbers (-n):

printf '%s\n' 'A-2001;SW1A 1AA' 'A-2002;sw1a 1aa' 'A-2003;SW1A1AA' \
  'A-2004;M1 1AE ' 'A-2005;B33 8TH' 'A-2006;GIR 0AA' > orders.csv

# Lines whose postcode (field 2) is NOT in the form "outward inward"
grep -nvE ';[A-Z]{1,2}[0-9][A-Z0-9]? [0-9][A-Z]{2}$' orders.csv
echo "---"
grep -cE ';[A-Z]{1,2}[0-9][A-Z0-9]? [0-9][A-Z]{2}$' orders.csv
2:A-2002;sw1a 1aa
3:A-2003;SW1A1AA
4:A-2004;M1 1AE
6:A-2006;GIR 0AA
---
2

Line 4 looks fine on screen. It fails because of an invisible trailing space; lines 2 and 3 only need normalizing, and line 6 is the historic exception the shape pattern does not cover.

Email addresses

The formal grammar for email addresses (RFC 5322) allows quoted local parts, comments and other things almost nobody uses. Even the HTML standard refuses to follow it. For <input type="email"> it defines its own, simpler pattern and calls it "a willful violation of RFC 5322", because that grammar is "simultaneously too strict (before the '@' character), too vague (after the '@' character), and too lax" to be useful. Here is that pattern, run in Node.js:

// The "valid email address" pattern from the HTML standard (type="email")
const htmlEmail = /^[a-zA-Z0-9.!#$%&'*+\/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/;

const addresses = [
  "amara.okafor@example.com",
  "billing+invoices@example.com",
  "a@b",
  "a..b@example.com",
  "amara okafor@example.com",
  "jörg@example.com",
  "amara@example..com",
];

for (const address of addresses) {
  console.log(htmlEmail.test(address) ? "accept" : "reject", address);
}
accept amara.okafor@example.com
accept billing+invoices@example.com
accept a@b
accept a..b@example.com
reject amara okafor@example.com
reject jörg@example.com
reject amara@example..com

PHP's built-in email filter gives different answers for the same list:

<?php
$addresses = [
    'amara.okafor@example.com',
    'billing+invoices@example.com',
    'a@b',
    'a..b@example.com',
    'amara okafor@example.com',
    'jörg@example.com',
    'amara@example..com',
];

foreach ($addresses as $address) {
    $ok = filter_var($address, FILTER_VALIDATE_EMAIL) !== false;
    echo ($ok ? 'accept' : 'reject'), ' ', $address, "\n";
}
accept amara.okafor@example.com
accept billing+invoices@example.com
reject a@b
reject a..b@example.com
reject amara okafor@example.com
reject jörg@example.com
reject amara@example..com

Two respected validators disagree about a@b (no dot in the domain) and a..b@example.com (two dots in a row). Neither is "wrong"; they made different trade-offs. Both reject jörg@example.com, although internationalized email standards do allow non-ASCII characters before the @, and some mail systems accept them. The lesson: no pattern is the correct email regex, so do not spend a day looking for one.

Confirm by sending

The only reliable test of an email address is to use it. A confirmation email is a message with a single-use link or code, sent to the address the user entered; the address counts as verified only after the user proves they received it. That catches typos (amara@exmaple.com is perfectly well-formed), proves the mailbox exists, and proves it belongs to this person. With that in place, the regex only has to catch obvious mistakes, so keep it loose: something like "no spaces, one @, something on both sides" is enough. A strict pattern that rejects a real customer's address costs more than a loose one that lets a typo through to the confirmation step.

Phone numbers

Phone numbers arrive as 06 12345678, +31 6 1234 5678, 0031-6-12345678 or with an extension. Inside the Netherlands a number starts with the trunk prefix 0 (06 for mobiles); after the country code +31 that 0 is dropped.

Phone numbers arrive as (202) 555-0142, 202-555-0142, 1-202-555-0142, +1 202 555 0142 or with an extension (x12). US numbers belong to the North American Numbering Plan (NANP, country code +1, shared with Canada and much of the Caribbean): a three-digit area code and a seven-digit local number, in the form NXX-NXX-XXXX, where N is a digit from 2 to 9. The numbers on this page use the range 555-0100 to 555-0199, which is reserved for fiction and advertising.

Phone numbers arrive as 020 7946 0018, +44 20 7946 0018, +44 (0)20 7946 0018, 0044 20 7946 0018 or with an extension. Inside the UK a number starts with the trunk prefix 0 (020 for London, 07 for most mobiles); after the country code +44 that 0 is dropped. The numbers on this page come from ranges Ofcom reserves for TV and radio drama, such as 020 7946 0000 to 0999 and 07700 900000 to 900999.

Trying to write one pattern that accepts every local habit is hopeless. Instead, store one standard form and convert input into it.

E.164: one format to store

E.164 is the ITU-T (International Telecommunication Union, standardization sector) numbering plan for international phone numbers. A number in E.164 form is a country code followed by the national number, at most 15 digits in total, usually written with a leading + and no spaces: +31612345678, +12025550142 or +442079460018. Its shape fits a short pattern: \+[1-9][0-9]{1,14} (country codes never start with 0).

import re

E164 = re.compile(r"\+[1-9][0-9]{1,14}")

def to_e164(raw):
    """Very rough clean-up: remove spaces, ( ) . -, turn a leading 00 into +."""
    digits = re.sub(r"[ ().-]", "", raw)
    if digits.startswith("00"):
        digits = "+" + digits[2:]
    return digits if E164.fullmatch(digits) else None

for raw in ["+31 6 12345678", "0031-6-1234-5678", "06 12345678",
            "+31 (0)6 12345678", "+31 6 1234 5678 ext. 12"]:
    print(f"{raw!r:26} -> {to_e164(raw)}")
'+31 6 12345678'           -> +31612345678
'0031-6-1234-5678'         -> +31612345678
'06 12345678'              -> None
'+31 (0)6 12345678'        -> +310612345678
'+31 6 1234 5678 ext. 12'  -> None

Look at the fourth line. The common notation (0) means "dial this 0 only inside the country", and the naive clean-up kept it. The result +310612345678 passes the E.164 pattern but is a wrong number. And 06 12345678 is rejected only because the code does not know which country the user is in. Both are problems of meaning that a pattern cannot solve: you need the numbering rules of each country.

import re

E164 = re.compile(r"\+[1-9][0-9]{1,14}")
NANP = re.compile(r"\+1[2-9][0-9]{2}[2-9][0-9]{6}")

def to_e164(raw):
    """Very rough clean-up: remove spaces, ( ) . -, turn a leading 011 into +."""
    digits = re.sub(r"[ ().-]", "", raw)
    if digits.startswith("011"):
        digits = "+" + digits[3:]
    return digits if E164.fullmatch(digits) else None

for raw in ["+1 (202) 555-0142", "011 1 202 555 0142", "(202) 555-0142",
            "1-202-555-0142", "+1 202 055 0142", "+1 202-555-0142 x12"]:
    number = to_e164(raw)
    nanp = number is not None and NANP.fullmatch(number) is not None
    print(f"{raw!r:23} -> {number}  NANP shape: {nanp}")
'+1 (202) 555-0142'     -> +12025550142  NANP shape: True
'011 1 202 555 0142'    -> +12025550142  NANP shape: True
'(202) 555-0142'        -> None  NANP shape: False
'1-202-555-0142'        -> None  NANP shape: False
'+1 202 055 0142'       -> +12020550142  NANP shape: False
'+1 202-555-0142 x12'   -> None  NANP shape: False

Look at the fifth line. +12020550142 passes the E.164 pattern, but an exchange code never starts with 0 or 1, so it cannot be a NANP number; the second pattern catches that. The third and fourth lines are rejected only because the code does not know what the user meant: (202) 555-0142 has no country code, and the leading 1 in 1-202-555-0142 is the long-distance prefix, which happens to be the same digit as the country code. Both are problems of meaning that a pattern cannot solve: you need the numbering rules of each country. (From the US, 011 is the prefix for international calls, where most of Europe uses 00.)

import re

E164 = re.compile(r"\+[1-9][0-9]{1,14}")

def to_e164(raw):
    """Very rough clean-up: remove spaces, ( ) . -, turn a leading 00 into +."""
    digits = re.sub(r"[ ().-]", "", raw)
    if digits.startswith("00"):
        digits = "+" + digits[2:]
    return digits if E164.fullmatch(digits) else None

for raw in ["+44 20 7946 0018", "0044-20-7946-0018", "020 7946 0018",
            "+44 (0)20 7946 0018", "+44 7700 900123 ext. 12"]:
    print(f"{raw!r:26} -> {to_e164(raw)}")
'+44 20 7946 0018'         -> +442079460018
'0044-20-7946-0018'        -> +442079460018
'020 7946 0018'            -> None
'+44 (0)20 7946 0018'      -> +4402079460018
'+44 7700 900123 ext. 12'  -> None

Look at the fourth line. The notation (0), common on British business cards, means "dial this 0 only inside the country", and the naive clean-up kept it. The result +4402079460018 passes the E.164 pattern but is a wrong number. And 020 7946 0018 is rejected only because the code does not know which country the user is in. Both are problems of meaning that a pattern cannot solve: you need the numbering rules of each country.

For real applications, use a dedicated library (Google's open-source libphonenumber and its ports are the usual choice) to parse, validate and format numbers, and confirm ownership with a one-time code by SMS when it matters.

URLs: use a parser

URLs look easy to match and are easy to get dangerously wrong. Imagine a login page that only redirects to your own site, checked with ^https://example\.com. The pattern checks that the text starts with those characters, not that the host is example.com.

A URL parser is code that splits a URL into its parts (scheme, user information, host, port, path, query and fragment) by the rules of a URL standard, the same way a browser or HTTP client will. Validate those parts instead of the raw text. In JavaScript the parser is the built-in URL class, which follows the WHATWG URL Standard that browsers use:

const naive = /^https:\/\/example\.com/;
const allowedHosts = new Set(["example.com", "www.example.com"]);

function isAllowed(text) {
  if (!URL.canParse(text)) return false;
  const url = new URL(text);
  return url.protocol === "https:" && allowedHosts.has(url.hostname);
}

for (const text of [
  "https://example.com/account",
  "https://EXAMPLE.com:443/account",
  "https://example.com.example.net/login",
  "https://example.com@example.org/",
  "http://example.com/",
  "not a url",
]) {
  console.log(naive.test(text), isAllowed(text), text);
}
true true https://example.com/account
false true https://EXAMPLE.com:443/account
true false https://example.com.example.net/login
true false https://example.com@example.org/
false false http://example.com/
false false not a url

The regex gets three of six wrong. It accepts a different host, example.com.example.net, and a URL whose host is really example.org: everything before the @ is user information, not the host. It also rejects a perfectly good URL written in capitals. The parser normalizes the host to lowercase and drops the default port, and the allowlist then compares exact values. That pattern (parse, then compare parts with an allowlist) is how open redirects and server-side request forgery checks should be written.

Python's urllib.parse.urlsplit() does the splitting. It is lenient: it rarely raises an error, so check that the parts you need are present.

from urllib.parse import urlsplit

for text in ["https://example.com:8443/meter/KE-0042?view=day#chart",
             "https://example.com@example.org/",
             "not a url"]:
    parts = urlsplit(text)
    print(repr(parts.scheme), repr(parts.hostname), parts.port, repr(parts.path))
'https' 'example.com' 8443 '/meter/KE-0042'
'https' 'example.org' None '/'
'' None None 'not a url'

See URL parts for what each component means. A regex is still fine for finding URL-like text in a log or a chat message; just do not let it make security decisions.

Dates, IP addresses and other values with meaning

The same rule applies to any value with internal rules. A date pattern can check that the month is 01 to 12, but not that February has no 30th; a dotted-quad pattern can check four groups of digits, but writing one that only allows 0 to 255 makes it long and still does not know about leading zeros. Let the regex check the format if you like, then hand the value to a parser:

<?php
// The format is right in both cases; only a real check knows the meaning.
$date = '2026-02-30';
$looksRight = preg_match('/^\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\d|3[01])$/', $date);
[$y, $m, $d] = array_map('intval', explode('-', $date));
var_dump($looksRight, checkdate($m, $d, $y));

$ip = '192.0.2.256';
var_dump(preg_match('/^\d{1,3}(\.\d{1,3}){3}$/', $ip));
var_dump(filter_var($ip, FILTER_VALIDATE_IP));
int(1)
bool(false)
int(1)
bool(false)

In Python the equivalents are datetime.date.fromisoformat() and ipaddress.ip_address(), both of which raise ValueError for impossible values. More date and address patterns are on practical patterns: logs, IP addresses and dates; the rules for the values themselves are in IPv4 addressing and dates and times in data formats.

Where validation happens

In the browser, the HTML pattern attribute gives instant feedback (see built-in form validation). The browser wraps the pattern as ^(?:…)$ and compiles it as a JavaScript regex with the v flag, so you write it without anchors and it must match the whole value:

<input name="postcode" pattern="[1-9][0-9]{3} ?[A-Za-z]{2}" required>

Anyone can switch that check off, edit the page or send the request without a browser at all. Treat browser validation as a usability feature and validate again on the server, with the same rules. Practical server-side habits:

  • Limit the length first. A length check is instant and protects every later step, including the regex itself (see performance and catastrophic backtracking).
  • Normalize, then validate, then store the canonical form, so the rest of the system only sees one spelling.
  • Allowlist explicit characters: [0-9] and [A-Z] rather than \d, \w or ., whose meaning differs per flavor and per Unicode setting.
  • Fail closed. If the regex engine reports an error (PHP's preg_match() returns false), treat the input as invalid, not as valid.
  • Give a helpful message that shows the expected form ("four digits and two letters, like 2511 CV"), not "invalid input".