Python Regex Tutorial: re.search, findall, sub and Validation with Examples

Sooner or later every programmer needs to find a pattern inside text: pull the dates out of a log file, check if an email address looks valid, clean up extra spaces in a name, or turn a title into a URL slug. You could write loops and if statements for each case, but there is a much shorter way: regular expressions (regex). Python supports them through the built-in re module, so there is nothing to install.

In this tutorial you will learn the Python regex functions you will actually use: re.search(), re.match(), re.fullmatch(), re.findall(), re.finditer(), re.sub() and re.split(), plus groups, flags and the greedy vs lazy trap. Every example is a small script you can run, and the output shown below each one was copied from the terminal after running it on Python 3.13.

Requirements

  • Python 3.8 or newer. The re module is part of the standard library.
  • A code editor such as VS Code, PyCharm or IDLE.
  • Basic Python knowledge: strings, lists, loops and functions.

Regex Cheat Sheet: The Symbols You Need First

PatternMeaningExample match
\dAny digit 0-97
\wLetter, digit or underscorea, _
\sWhitespace (space, tab, newline)" "
.Any character except newlinex
[abc] / [^abc]One of a, b, c / anything except themb
+ * ?One or more / zero or more / optional\d+ matches 1042
{n} / {n,m}Exactly n / between n and m times\d{4} matches 2026
^ $Start / end of the string (or line)
( )Group: capture part of the match
|ORERROR|WARNING

One habit to start with: always write patterns as raw strings, like r"\d+". The r prefix stops Python from treating backslashes as escape characters before the regex engine even sees them.

Step 1: re.search(), re.match() and re.fullmatch()

These three functions return a match object when the pattern is found and None when it is not. The difference is where they look. Save this as 1_search_match.py:

  1. import re
  2.  
  3. text = "Order #1042 was paid on 2026-11-05 by [email protected]"
  4.  
  5. # re.search() scans the whole string and returns the FIRST match (or None)
  6. m = re.search(r"\d+", text)
  7. print("search:", m.group(), "at position", m.start())
  8.  
  9. # re.match() only checks the BEGINNING of the string
  10. print("match 'Order':", re.match(r"Order", text))
  11. print("match digits:", re.match(r"\d+", text))
  12.  
  13. # re.fullmatch() requires the WHOLE string to match the pattern
  14. print("fullmatch 1042:", bool(re.fullmatch(r"\d+", "1042")))
  15. print("fullmatch 1042a:", bool(re.fullmatch(r"\d+", "1042a")))
  16.  
  17. # Always check for None before calling .group()
  18. m = re.search(r"Invoice", text)
  19. if m:
  20.     print(m.group())
  21. else:
  22.     print("No 'Invoice' found")

Run it with python 1_search_match.py. Output:

search: 1042 at position 7
match 'Order': <re.Match object; span=(0, 5), match='Order'>
match digits: None
fullmatch 1042: True
fullmatch 1042a: False
No 'Invoice' found

Remember it this way:

  • search() looks anywhere in the string. This is the one you will use most.
  • match() only checks the start of the string. That is why \d+ returned None: the text starts with "Order", not a number.
  • fullmatch() requires the entire string to match. Use it for validation, so that "1042a" is rejected.

Calling .group() on None raises AttributeError: 'NoneType' object has no attribute 'group', one of the most common regex errors. Always check the result first, as the last part of the script does.

Step 2: Find All Matches and Capture Groups

re.findall() returns every match as a list. When the pattern contains groups, it returns only the captured parts. For the full match objects, use re.finditer(). Here we read a small log file:

  1. import re
  2.  
  3. log = """2026-11-03 ERROR Database connection failed
  4. 2026-11-04 INFO User ana logged in
  5. 2026-11-05 ERROR Disk almost full
  6. 2026-11-05 WARNING Slow query (2.4s)"""
  7.  
  8. # findall() returns every match as a list of strings
  9. print(re.findall(r"\d{4}-\d{2}-\d{2}", log))
  10.  
  11. # With groups ( ), findall() returns tuples of the captured parts
  12. for date, level in re.findall(r"^(\S+) (ERROR|WARNING)", log, re.MULTILINE):
  13.     print(date, "->", level)
  14.  
  15. # Named groups make the code easier to read
  16. pattern = re.compile(r"(?P<year>\d{4})-(?P<month>\d{2})-(?P<day>\d{2}) (?P<level>[A-Z]+) (?P<message>.+)")
  17. for m in pattern.finditer(log):
  18.     print(m.group("day"), m.group("level"), "|", m.group("message"))
  19.  
  20. m = pattern.search(log)
  21. print(m.groupdict())

Run it with python 2_findall_groups.py. Output:

['2026-11-03', '2026-11-04', '2026-11-05', '2026-11-05']
2026-11-03 -> ERROR
2026-11-05 -> ERROR
2026-11-05 -> WARNING
03 ERROR | Database connection failed
04 INFO | User ana logged in
05 ERROR | Disk almost full
05 WARNING | Slow query (2.4s)
{'year': '2026', 'month': '11', 'day': '03', 'level': 'ERROR', 'message': 'Database connection failed'}

A few things happened here:

  • \d{4}-\d{2}-\d{2} found all four dates.
  • With two groups, findall() returned (date, level) tuples, which we unpacked directly in the for loop.
  • re.MULTILINE makes ^ match at the start of every line, not just the start of the whole text.
  • Named groups (?P<name>...) let you write m.group("level") instead of m.group(4), and groupdict() gives you a ready-made dictionary.
  • re.compile() builds the pattern once so you can reuse it. Python caches recently used patterns anyway, but compiling makes the code cleaner when you use a pattern many times.

Step 3: Validate Email, Mobile Number, Student ID and Password

Form validation is the most popular use of regex in student projects. Combine fullmatch() with a pattern for each field:

  1. import re
  2.  
  3. EMAIL = re.compile(r"[\w.+-]+@[\w-]+(\.[\w-]+)+")
  4. PH_MOBILE = re.compile(r"(\+63|0)9\d{9}")
  5. STUDENT_ID = re.compile(r"\d{4}-\d{5}")
  6. STRONG_PASSWORD = re.compile(r"(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[^\w\s]).{8,}")
  7.  
  8. tests = {
  9.     "email": (EMAIL, ["[email protected]", "[email protected]", "carla@", "dan@@mail.com"]),
  10.     "mobile": (PH_MOBILE, ["09171234567", "+639171234567", "0917123456", "9171234567"]),
  11.     "student id": (STUDENT_ID, ["2026-00123", "2026-123", "26-00123"]),
  12.     "password": (STRONG_PASSWORD, ["Passw0rd!", "password", "Short1!", "NoSymbol123"]),
  13. }
  14.  
  15. for name, (pattern, values) in tests.items():
  16.     print(f"--- {name}")
  17.     for v in values:
  18.         ok = pattern.fullmatch(v) is not None
  19.         print(f"{v:<26} {'valid' if ok else 'INVALID'}")

Run it with python 3_validate.py. Output:

--- email
[email protected]     valid
[email protected]          valid
carla@                     INVALID
dan@@mail.com              INVALID
--- mobile
09171234567                valid
+639171234567              valid
0917123456                 INVALID
9171234567                 INVALID
--- student id
2026-00123                 valid
2026-123                   INVALID
26-00123                   INVALID
--- password
Passw0rd!                  valid
password                   INVALID
Short1!                    INVALID
NoSymbol123                INVALID

How the patterns work:

  • Email [\w.+-]+@[\w-]+(\.[\w-]+)+: a name, one @, a domain, and at least one ".something" part. It is a practical check, not a full RFC validator. The only real proof that an email works is sending a confirmation message.
  • Philippine mobile (\+63|0)9\d{9}: starts with +63 or 0, then 9, then nine more digits.
  • Student ID \d{4}-\d{5}: four digits, a dash, five digits. Change the numbers to fit your school's format.
  • Strong password uses lookaheads (?=...). Each one checks a rule (a lowercase letter, an uppercase letter, a digit, a symbol) without consuming characters, and .{8,} requires at least 8 characters.

Validate on the server too, not only with JavaScript in the browser, because browser checks are easy to bypass.

Step 4: Replace and Split Text with re.sub() and re.split()

re.sub(pattern, replacement, text) replaces every match. The replacement can refer to groups with \1, \2 and so on, or it can be a function that computes the new text:

  1. import re
  2.  
  3. # re.sub() replaces every match
  4. messy = "  Juan   Dela    Cruz  "
  5. print(repr(re.sub(r"\s+", " ", messy).strip()))
  6.  
  7. # Use groups in the replacement: change 11/05/2026 to 2026-11-05
  8. print(re.sub(r"(\d{2})/(\d{2})/(\d{4})", r"\3-\1-\2", "Due: 11/05/2026, Paid: 11/20/2026"))
  9.  
  10. # Mask a mobile number except the last 4 digits
  11. print(re.sub(r"\d(?=\d{4})", "*", "09171234567"))
  12.  
  13. # A function as the replacement: add 12% VAT to every price
  14. def add_vat(m):
  15.     price = float(m.group(1))
  16.     return f"P{price * 1.12:.2f}"
  17. print(re.sub(r"P(\d+(?:\.\d+)?)", add_vat, "Coffee P50, Bread P35.50, Milk P90"))
  18.  
  19. # re.split() splits on a pattern, not just one character
  20. print(re.split(r"[,;|]\s*", "php, python;java|  sql"))
  21.  
  22. # Make a URL slug from a title
  23. title = "Python Regex Tutorial: re.search, findall & sub!"
  24. slug = re.sub(r"[^a-z0-9]+", "-", title.lower()).strip("-")
  25. print(slug)

Run it with python 4_sub_split.py. Output:

'Juan Dela Cruz'
Due: 2026-11-05, Paid: 2026-11-20
*******4567
Coffee P56.00, Bread P39.76, Milk P100.80
['php', 'python', 'java', 'sql']
python-regex-tutorial-re-search-findall-sub

Each line shows a real task:

  • \s+ squeezes any run of spaces into one, cleaning names typed with extra spaces.
  • r"\3-\1-\2" rearranges the captured month, day and year into the database-friendly YYYY-MM-DD format.
  • \d(?=\d{4}) matches a digit only if four more digits follow it, so the last four digits stay visible.
  • When the replacement is a function, Python calls it for every match, so you can do math with the matched value.
  • re.split(r"[,;|]\s*", ...) splits on commas, semicolons or pipes and removes the spaces after them in one step.

Step 5: Flags, Greedy vs Lazy, and re.escape()

The last script covers the details that cause the most confusing bugs:

  1. import re
  2.  
  3. # re.IGNORECASE: match regardless of upper/lower case
  4. print(re.findall(r"python", "Python, PYTHON and python", re.IGNORECASE))
  5.  
  6. # Greedy vs lazy: .* takes as much as possible, .*? as little as possible
  7. html = "<b>Name</b> and <b>Score</b>"
  8. print("greedy:", re.findall(r"<b>(.*)</b>", html))
  9. print("lazy:  ", re.findall(r"<b>(.*?)</b>", html))
  10.  
  11. # re.VERBOSE lets you write a long pattern on several lines with comments
  12. price = re.compile(r"""
  13.    P               # peso sign written as P
  14.    (\d{1,3}(?:,\d{3})*)   # whole number with optional thousands commas
  15.    (?:\.(\d{2}))?  # optional centavos
  16. """, re.VERBOSE)
  17. for m in price.finditer("Laptop P45,999.00, Mouse P350, Bag P1,250.75"):
  18.     print(m.group(0), "->", m.group(1), m.group(2))
  19.  
  20. # Escape user input before putting it inside a pattern
  21. user_search = "C++ (basics)"
  22. print(re.escape(user_search))
  23. print(bool(re.search(re.escape(user_search), "Intro to C++ (basics) course")))

Run it with python 5_flags_greedy.py. Output:

['Python', 'PYTHON', 'python']
greedy: ['Name</b> and <b>Score']
lazy:   ['Name', 'Score']
P45,999.00 -> 45,999 00
P350 -> 350 None
P1,250.75 -> 1,250 75
C\+\+\ \(basics\)
True
  • re.IGNORECASE (or re.I) ignores letter case.
  • Greedy vs lazy: .* grabs as much text as it can, so <b>(.*)</b> swallowed everything from the first <b> to the last </b>. Adding ? (.*?) makes it stop at the first possible end. Note that for real HTML pages you should use a parser such as BeautifulSoup; regex is fine for small, predictable snippets.
  • re.VERBOSE lets you spread a long pattern over several lines and add comments. Spaces inside the pattern are ignored in this mode.
  • re.escape() escapes special characters like + and (. Use it whenever you put user input inside a pattern; otherwise a search for "C++" raises an error or matches the wrong thing.

Common Python Regex Mistakes

  • Forgetting the r prefix. "\b" in a normal string is a backspace character, while r"\b" is a word boundary.
  • Using match() when you meant search(). If your pattern "never matches", check this first.
  • Using search() for validation. re.search(r"\d{4}", "abc12345xyz") succeeds. Use fullmatch() when the whole input must follow the format.
  • Not checking for None before calling .group().
  • Writing one giant pattern. If a regex becomes hard to read, split the job into smaller steps or use re.VERBOSE with comments.

Quick Reference

FunctionReturnsUse it to
re.search(p, s)First match or NoneFind a pattern anywhere
re.match(p, s)Match at start or NoneCheck how a string begins
re.fullmatch(p, s)Match or NoneValidate input
re.findall(p, s)List of strings/tuplesExtract all values
re.finditer(p, s)Iterator of match objectsExtract with positions and named groups
re.sub(p, r, s)New stringReplace, clean or reformat text
re.split(p, s)List of stringsSplit on several separators

Conclusion

Regular expressions look scary at first, but you only need a handful of symbols and seven functions to handle most real tasks: search() to find, fullmatch() to validate, findall() and finditer() to extract, and sub() and split() to transform text. Start with the examples above, change the patterns, and watch how the output changes. That is the fastest way to get comfortable with regex. You can download all five example scripts below.

Happy coding!