Regex Deliverable: Invoice IDs in Email Subject Lines
1. Summary
This regex finds every invoice ID like INV-2024-00123 inside longer subject lines. It captures the 4-digit year and the 1–5 digit invoice number as separate named groups. It targets Python 3's re module, searches within text (not full-string), and uses re.IGNORECASE. It is built for re.finditer, so it returns every ID on a line.
Assumptions
- Year: any 4 digits (
0000–9999). The goal says "4-digit year," so no range check is applied. A stricter variant is in section 7. - Number as text: the invoice number is captured as a string, so leading zeros are kept (
00042, not42). Useint(...)afterward if you need a number. - No glued characters: an ID must not have a letter, digit, or underscore stuck to either end. That is why
XINV-…andINV-2024-123456are rejected. Punctuation, spaces, brackets, colons, or the start or end of the line are all fine as neighbors. - Exact format: no spaces are allowed around the dashes, and only plain dashes (
-) are accepted. - ASCII digits only: the pattern uses
[0-9]instead of\d, so non-ASCII digits (such as Arabic-Indic٢٠٢٤) don't count. - No conflicts: the goal and the examples agree, so nothing needed to be resolved.
2. The Regex
\bINV-(?P<year>[0-9]{4})-(?P<number>[0-9]{1,5})\b
Flags: re.IGNORECASE (pass it as the second argument to re.compile). You don't need re.MULTILINE, because no ^ or $ anchors are used.
Python literal (copy-paste ready):
import re
INVOICE_RE = re.compile(
r"\bINV-(?P<year>[0-9]{4})-(?P<number>[0-9]{1,5})\b",
re.IGNORECASE,
)
for m in INVOICE_RE.finditer(subject):
year, number = m.group("year"), m.group("number")
| Group # / Name | Captures | Example value |
|---|---|---|
1 / year |
The 4-digit year | 2024 |
2 / number |
The 1–5 digit invoice number, leading zeros kept | 00123 |
| 0 (whole match) | The full ID, in its original case | INV-2024-00123 |
3. How It Works
| Piece | Meaning |
|---|---|
\b |
Word boundary. The character before INV must not be a letter, digit, or underscore. This blocks XINV-…. |
INV |
The literal letters INV. They match in any case because of re.IGNORECASE. |
- |
A literal dash. |
(?P<year>[0-9]{4}) |
Exactly 4 digits, saved as year. |
- |
A literal dash. |
(?P<number>[0-9]{1,5}) |
1 to 5 digits, saved as number. |
\b |
Word boundary. The next character must not be a letter, digit, or underscore. This blocks a 6th digit, so INV-2024-123456 is rejected rather than cut down to 12345. |
4. Test Results
Results below are traced by hand, not executed.
| Input | Expected | Result | Captured / matched text | Note |
|---|---|---|---|---|
INV-2024-00123 |
Match | ✅ | INV-2024-00123 → 2024 / 00123 |
Basic case |
inv-2023-9 |
Match | ✅ | inv-2023-9 → 2023 / 9 |
Case-insensitive; 1-digit number |
Re: INV-2025-00042 (paid) |
Match | ✅ | INV-2025-00042 → 2025 / 00042 |
Surrounding text ignored |
Invoices INV-2024-1 and INV-2024-22 attached |
2 matches | ✅ | INV-2024-1 → 2024 / 1; INV-2024-22 → 2024 / 22 |
"Invoices" starts with "Inv" but fails at the dash |
INV-24-00123 |
No match | ✅ | — | Year has only 2 digits |
INV2024-00123 |
No match | ✅ | — | Missing dash after INV |
PO-2024-00123 |
No match | ✅ | — | Wrong prefix |
INV-2024-123456 |
No match | ✅ | — | 6 digits; trailing \b fails at every backtrack point |
XINV-2024-00123 |
No match | ✅ | — | No word boundary between X and I |
| (empty string) (added) | No match | ✅ | — | Nothing to match |
INV-2024-12345 (added) |
Match | ✅ | INV-2024-12345 → 2024 / 12345 |
Maximum length of 5 digits |
Inv-2024-7, INV-2024-8. (added) |
2 matches | ✅ | Inv-2024-7 → 2024 / 7; INV-2024-8 → 2024 / 8 |
Mixed case; comma and period count as boundaries |
(INV-2024-00123) (added) |
Match | ✅ | INV-2024-00123 → 2024 / 00123 |
Brackets count as boundaries |
RE:FW:INV-2024-5 (added) |
Match | ✅ | INV-2024-5 → 2024 / 5 |
Colon right before the ID |
INV - 2024 - 5 (added) |
No match | ✅ | — | Spaces around dashes not allowed |
INV-2024- (added) |
No match | ✅ | — | Number missing |
INV-20245-1 (added) |
No match | ✅ | — | After 4 year digits, a 5 appears where - is required |
INV-2024-00123A (added) |
No match | ✅ | — | A letter glued to the end |
INVINV-2024-1 (added) |
No match | ✅ | — | The second INV has no boundary before it |
INV-٢٠٢٤-1 (added) |
No match | ✅ | — | Non-ASCII digits are rejected by [0-9] |
INV-2024-00123_draft (added) |
No match | ⚠️ | — | Trade-off: _ counts as a word character, so the ID is not found. See section 7 if this is common. |
INV-2024-1-2 (added) |
Match | ⚠️ | INV-2024-1 → 2024 / 1 |
Trade-off: a dash counts as a boundary, so a trailing -2 is ignored rather than rejecting the whole thing |
5. Ready-to-Run Tests
import re
INVOICE_RE = re.compile(
r"\bINV-(?P<year>[0-9]{4})-(?P<number>[0-9]{1,5})\b",
re.IGNORECASE,
)
def find_ids(text):
return [(m.group("year"), m.group("number")) for m in INVOICE_RE.finditer(text)]
CASES = [
# Customer positives
("INV-2024-00123", [("2024", "00123")]),
("inv-2023-9", [("2023", "9")]),
("Re: INV-2025-00042 (paid)", [("2025", "00042")]),
("Invoices INV-2024-1 and INV-2024-22 attached", [("2024", "1"), ("2024", "22")]),
# Customer negatives
("INV-24-00123", []),
("INV2024-00123", []),
("PO-2024-00123", []),
("INV-2024-123456", []),
("XINV-2024-00123", []),
# Added edge cases
("", []),
("INV-2024-12345", [("2024", "12345")]),
("Inv-2024-7, INV-2024-8.", [("2024", "7"), ("2024", "8")]),
("(INV-2024-00123)", [("2024", "00123")]),
("RE:FW:INV-2024-5", [("2024", "5")]),
("INV - 2024 - 5", []),
("INV-2024-", []),
("INV-20245-1", []),
("INV-202