Regular expressions
Problem
Ten lines for one date
The booking form wants a date in the form dd.mm.yyyy. You check it character by character:
def is_date(s): if len(s) != 10: return False for i in range(len(s)): c = s[i] if i == 2 or i == 5: if c != ".": return False elif c < "0" or c > "9": return False return True
print(is_date("05.03.2026"))print(is_date("5.3.2026"))print(is_date("05/03/2026"))TrueFalseFalseTen lines, and the function checks only the format: two digits, a dot, two digits, a dot, four digits. If you also want to accept / instead of a dot, or a day with one digit, you will add as many lines again. A phone number, an email or a license plate will cost you one more function like this each.
The idea
A pattern instead of code
You describe what the text looks like, and Python does the checking:
import re
def is_date(s): return re.fullmatch(r"\d{2}\.\d{2}\.\d{4}", s) is not None
print(is_date("05.03.2026"))print(is_date("5.3.2026"))print(is_date("05/03/2026"))TrueFalseFalseYou read the pattern from left to right: \d is a digit, {2} means "two times", \. is a dot. A pattern like this is called a regular expression (regex).
Regular expressions live in the re module, so the file starts with import re. re.fullmatch(pattern, text) checks whether the whole text fits the pattern, from the first character to the last. If it does, it returns the match. If not, it returns None. That is why is not None gives True or False.
Why is there an r before the quotes? The backslash is special for an ordinary Python string too. With r the string is raw, and \ in it is just \. We will look at this in detail in a moment. For now, write every pattern with r.
See it
How the pattern searches
re.sub does not check the whole text. It searches for the pieces that fit the pattern and replaces them. Here is how it goes along the line:
import re
line = "€120 on 05.03.2026, due 15.04.2026"masked = re.sub(r"\d{2}\.\d{2}\.\d{4}", "[date]", line)print(masked)Step by step, in words
- The line is a sequence of characters, each with its own position.
- From position 0: "€" is not a digit. From position 1: "1" and "2" are digits, but then comes "0" instead of a dot. The try fails.
- A new try from each next character: "20 ", "0 ", " o"… all failures.
- From position 8 all ten parts fit: the first match.
- The search carries on after the match, from the comma, and finds the second one.
- re.sub replaces both matches with "[date]".
The pattern is tried from each position in turn. As soon as some part does not match, the try fails and a new one starts from the next character. That is why 120 did not pass: two digits, and then 0 instead of a dot. When it finds a match, the search carries on after it.
The idea
Character classes
In square brackets you list which characters are allowed in one place. A car license plate is one or two letters, four digits and two letters:
import re
plates = ["CA1234BX", "B7788KK", "CA12345BX", "ca1234bx"]for plate in plates: print(plate, re.fullmatch(r"[A-Z]{1,2}\d{4}[A-Z]{2}", plate) is not None)CA1234BX TrueB7788KK TrueCA12345BX Falseca1234bx False| Pattern | Matches one character |
|---|---|
[ABE] |
A, B or E |
[0-9], [A-Z] |
from the range: a digit, a capital Latin letter |
[^0-9] |
anything except a digit (^ at the start of the brackets) |
\d |
a digit, the same as [0-9] |
\s |
a space, a tab, a new line |
\w |
a letter from any language, a digit or _ |
. |
any character at all (except a new line) |
Capital letters flip the meaning: \D is "not a digit", \S is "not a space", \W is "not \w".
In Python \w also knows letters like "ü" and "é". With the re.ASCII flag (the third argument of fullmatch) it becomes Latin letters only, as in Java and JavaScript:
import re
print(re.fullmatch(r"\w+", "Sofia") is not None)print(re.fullmatch(r"\w+", "Zürich") is not None)print(re.fullmatch(r"\w+", "Zürich", re.ASCII) is not None)print(re.fullmatch(r"[A-Za-zü]+", "Zürich") is not None)TrueTrueFalseTrueMore precisely, if you are curious
[A-Z]is a range by the codes of the characters. It has only the 26 Latin letters, without "É" or "Ü", which sit elsewhere in the table. For English text[A-Za-z]is enough. If you need letters with accents too, add them:[A-Za-zÉéÜü].- Real Bulgarian plates use only 12 letters, the ones that look the same in Cyrillic and Latin:
[ABEKMHOPCTYX].
Try it
The dot
Someone forgot the backslash before the dots:
What will the program print?
import re
pattern = r"\d{2}.\d{2}.\d{4}"print(re.fullmatch(pattern, "05.03.2026") is not None)print(re.fullmatch(pattern, "05-03-2026") is not None)print(re.fullmatch(pattern, "05.03.26") is not None)Without \ the dot means "any character at all", so the dash passes too, and 05-03-2026 is true. The third text has only two digits for the year, and the pattern wants four. The check looks like it works until wrong input comes along. So test the pattern with texts that should not pass too.
The idea
How many times
After a character or a class you say how many times it can repeat. This is a quantifier:
| Quantifier | Means |
|---|---|
? |
zero or one (it may be there, it may not) |
* |
zero or more |
+ |
one or more |
{4} |
exactly 4 |
{1,2} |
from 1 to 2 |
{2,} |
2 or more |
A Bulgarian mobile number starts with 0 or +359, then 87, 88 or 89, and 7 more digits:
import re
mobile = r"(\+359|0)8[789]\d{7}"print(re.fullmatch(mobile, "0888123456") is not None)print(re.fullmatch(mobile, "+359877654321") is not None)print(re.fullmatch(mobile, "0888 123 456") is not None)print(re.fullmatch(mobile, "088812345") is not None)TrueTrueFalseFalse| means "or". The parentheses keep \+359|0 together: without them, "or" would split the whole pattern in two. Parentheses do more things, which we will see in the next lesson. The + at the start has a \, because otherwise it is a quantifier.
A quantifier also works on a whole group in parentheses. Here is a price with or without a fractional part:
import re
price = r"€\d+([.,]\d{1,2})?"print(re.fullmatch(price, "€12") is not None)print(re.fullmatch(price, "€12.5") is not None)print(re.fullmatch(price, "€12,50") is not None)print(re.fullmatch(price, "€.50") is not None)TrueTrueTrueFalse([.,]\d{1,2})? means "a dot or a comma and one or two digits", and this whole block may be missing.
The idea
Raw string: r"…"
The pattern goes through two layers. First Python reads the string in the quotes, then re reads the pattern.
In an ordinary string, \ starts a special character: \n is a new line, \" is a quote. \d is not one of them. Python 3.12 warns you, but leaves \d as it is, and the program works:
import re
date = "05.03.2026"print(re.fullmatch("\d{2}\.\d{2}\.\d{4}", date) is not None)/shop/date.py:4: SyntaxWarning: invalid escape sequence '\d' print(re.fullmatch("\d{2}\.\d{2}\.\d{4}", date) is not None)TrueIt works by luck. \b, for example, is a special character in a string ("backspace"), and a pattern with it but without r quietly searches for something else. You will see it in a moment. That is why patterns are written as raw strings: with r before the quotes, \ is just \:
import re
date = "05.03.2026"print(re.fullmatch(r"\d{2}\.\d{2}\.\d{4}", date) is not None)print(re.fullmatch(r"C:\\Temp", "C:\\Temp") is not None)TrueTrue| You want in the pattern | You write in Python | Without r |
|---|---|---|
\d (a digit) |
r"\d" |
"\\d" |
\. (a real dot) |
r"\." |
"\\." |
\\ (a real backslash) |
r"\\" |
"\\\\" |
The characters that need a \ when you want them literally are . * + ? ( ) [ ] { } | ^ $ \.
Try it
Find the bug
Traceback (most recent call last): File "/shop/price.py", line 5, in <module> euros = int(parts[0]) ^^^^^^^^^^^^^ValueError: invalid literal for int() with base 10: ''Pick the line with the bug.
re.split takes a regular expression. "." is "any character", so every character is a separator and there is nothing between them: the parts are six empty strings. The error shows up only on the next line, when int gets an empty string. The fix is re.split(r"\.", price). But here you do not need a pattern at all: price.split(".") (the string method) searches for plain text.price = "12.50"print(price.split("."))['12', '50']The idea
Replacing and splitting by a pattern
re.sub(pattern, replacement, text) and re.split(pattern, text) work with a pattern. This is how you clean up and break apart text that people type any way they like:
import re
address = "Delivery: 12 Shipka St., entrance B"print(re.sub(r"\s+", " ", address))items = re.split(r"[,;\s]+", "bread, milk;coffee tea")print(f"{len(items)}: " + " | ".join(items))print(re.sub(r"(?i)summer\d+", "***", "Code SUMMER10, code summer25"))Delivery: 12 Shipka St., entrance B4: bread | milk | coffee | teaCode ***, code ***\s+is "one or more spaces": every group of spaces becomes one.[,;\s]+is "commas, semicolons or spaces, however many there are": the separator can be any of them.
(?i)at the start is a flag: the pattern ignores upper and lower case.
The string method replace also replaces every occurrence, but it searches for plain text, not a pattern. If you do not need a pattern, use it: you do not have to think about dots and backslashes.
\b is a word boundary: the place between a letter and something that is not a letter. With it you replace only whole words. In Python \b knows letters like "é", but the pattern has to be a raw string:
import re
text = "éclair and éclairs"print(re.sub("éclair", "donut", text))print(re.sub("\béclair\b", "donut", text))print(re.sub(r"\béclair\b", "donut", text))donut and donutséclair and éclairsdonut and éclairsWithout \b, "éclairs" becomes "donuts". The second line has no r: there \b is the backspace character, not a boundary, so nothing is replaced and there is no error. Only with the raw string on the third line is just the word "éclair" replaced.
More precisely, if you are curious
\Bis the opposite of\b: a place inside a word.r"\Bé"finds "é" only when it is not at the start of a word: in "café", but not in "éclair".^is the start of the text, and$is the end:re.sub(r"^0+", "", text)removes the leading zeros. You do not need them withfullmatch, because it looks at the whole text anyway.- You can also give the flags as constants:
re.IGNORECASEis the same as(?i), andre.ASCIIas(?a). You pass them as the last argument:re.sub(pattern, replacement, text, flags=re.IGNORECASE). re.split("|", "a|b")is a trap like the dot:|is "or", and the pattern is empty, so it splits between every two characters. The string method"a|b".split("|")searches for plain text and does not have this problem.
import re
print(re.sub(r"\Bé", "e", "café éclair"))print(re.fullmatch(r"(?a)\w+", "Zürich"), re.fullmatch(r"(?i)éclair", "ÉCLAIR") is not None)print(re.split("|", "a|b"))print("a|b".split("|"))cafe éclairNone True['', 'a', '|', 'b', '']['a', 'b']Try it
Postal code
A Bulgarian postal code is exactly four digits. What is missing for the program to print True for "1000" and False for "100" or "10000"?
import re
code = "1000"
print(re.fullmatch(r"", code) is not None)\d{4}: exactly four digits. Because fullmatch checks the whole text, three or five digits do not pass.Exercise
Checks with a pattern
Solve the tasks in your editor. Read lines while there are any: for line in sys.stdin:, with import sys at the top. Each line ends with "\n", so remove it with strip() or rstrip("\n"). In the lesson "Files" you will read the same lines from a file.
1. Date. Check whether each date is in the form dd/mm/yyyy. The day is from 01 to 31, and the month from 01 to 12. Hint: the day is 0[1-9] or [12]\d, or 3[01].
01/01/202032/01/202015/05/202101/01/2020 - Valid32/01/2020 - Invalid15/05/2021 - Valid2. Mobile number. People write numbers with spaces and dashes. Remove them with re.sub and check what is left with the mobile number pattern from the lesson.
0888 123 456+359 87 765 432102 812 34560899-12-3450888 123 456 - valid+359 87 765 4321 - valid02 812 3456 - invalid0899-12-345 - invalid3. Email. Check whether each line looks like an email: one or more Latin letters, digits, ., + or -, then @, then a domain name with at least one dot (company.com, mail.company.com). Here \w would also let in letters like "ü": list the Latin letters with [A-Za-z0-9_].
elena.koleva@company.comelena@company@company.comelena koleva@company.comelena.koleva@company.com - yeselena@company - no@company.com - noelena koleva@company.com - no4. Shopping list. The products are separated with commas, semicolons, vertical bars or spaces, sometimes several at once. Print them numbered.
bread, milk;coffee tea|cheese1. bread2. milk3. coffee4. tea5. cheeseMore precisely, if you are curious
- The date pattern also accepts
31/02/2026. A regex cannot sensibly tell whether the day exists in the month. For that there is thedatetimemodule, and the pattern is only the first sieve:
import datetime
try: datetime.date(2026, 2, 31)except ValueError as e: print(e)day is out of range for month- The full rule for an email address (RFC 5322) allows quotes, comments and spaces in certain places. A pattern that covers all of it is hundreds of characters long, and nobody maintains it. The real check is to send an email with a confirmation link.
More tasks
- Password. Check whether the password is at least 8 characters long and has at least one digit. Do it with two
re.fullmatch:r".{8,}"andr".*\d.*". - Time. Check a time in the form
hh:mm, from00:00to23:59. - Digits only. From
"Tel.: +359 (88) 812-34-56"keep only the digits and the+, with onere.suband[^...].
Test