Skip to content
wannacode

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"))
Output
True
False
False

Ten 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"))
Output
True
False
False

You 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
  1. The line is a sequence of characters, each with its own position.
  2. 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.
  3. A new try from each next character: "20 ", "0 ", " o"… all failures.
  4. From position 8 all ten parts fit: the first match.
  5. The search carries on after the match, from the comma, and finds the second one.
  6. 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)
Output
CA1234BX True
B7788KK True
CA12345BX False
ca1234bx 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)
Output
True
True
False
True
More 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)
Answer

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)
Output
True
True
False
False

| 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)
Output
True
True
True
False

([.,]\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)
The console
/shop/date.py:4: SyntaxWarning: invalid escape sequence '\d'
print(re.fullmatch("\d{2}\.\d{2}\.\d{4}", date) is not None)
True

It 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)
Output
True
True
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

The program should print "12 euros and 50 cents", but it stops like this:
Error
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.

price = "12.50"
print(price.split("."))
Output
['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"))
Output
Delivery: 12 Shipka St., entrance B
4: bread | milk | coffee | tea
Code ***, 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))
Output
donut and donuts
éclair and éclairs
donut and éclairs

Without \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
  • \B is 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 with fullmatch, because it looks at the whole text anyway.
  • You can also give the flags as constants: re.IGNORECASE is the same as (?i), and re.ASCII as (?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("|"))
Output
cafe éclair
None 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)

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].

Input
01/01/2020
32/01/2020
15/05/2021
Output
01/01/2020 - Valid
32/01/2020 - Invalid
15/05/2021 - Valid

2. 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.

Input
0888 123 456
+359 87 765 4321
02 812 3456
0899-12-345
Output
0888 123 456 - valid
+359 87 765 4321 - valid
02 812 3456 - invalid
0899-12-345 - invalid

3. 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_].

Input
elena.koleva@company.com
elena@company
@company.com
elena koleva@company.com
Output
elena.koleva@company.com - yes
elena@company - no
@company.com - no
elena koleva@company.com - no

4. Shopping list. The products are separated with commas, semicolons, vertical bars or spaces, sometimes several at once. Print them numbered.

Input
bread, milk;coffee tea|cheese
Output
1. bread
2. milk
3. coffee
4. tea
5. cheese
More 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 the datetime module, and the pattern is only the first sieve:
import datetime
try:
datetime.date(2026, 2, 31)
except ValueError as e:
print(e)
Output
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,}" and r".*\d.*".
  • Time. Check a time in the form hh:mm, from 00:00 to 23:59.
  • Digits only. From "Tel.: +359 (88) 812-34-56" keep only the digits and the +, with one re.sub and [^...].

Test

Check yourself

1. What does re.fullmatch(r"\d{4}", "2026") check?
2. What does . (a dot) match in a pattern?
3. Which one means "one or more"?
4. Does "Zürich" match \w+ in Python?
5. How many parts does re.split(r"[,;]\s*", "bread, milk;coffee") give?

Made by Ivo Gergov as part of Evo Tech's campaign for accessible programming education ·