Imagine you run the admissions helpdesk for a school. Every day, parents send messages like these:
- "Please call me at 9876543210 after 5pm"
- "My WhatsApp is +91-8123456780, thanks"
- "Reach me on 98765 43210 or the landline"
You need a Python script that pulls every phone number out of hundreds of such messages, so the front office can call parents back. Your first instinct might be text.find("9876543210") — but that only works if you already know the exact number you're searching for, which defeats the purpose. You don't want one specific number. You want anything that looks like a phone number: ten digits in a row, maybe with a country code, maybe with spaces or dashes mixed in.
This is the exact problem regular expressions were invented to solve: describing the shape of text you want to find, not its exact content. A regular expression (or "regex") is a mini-language for writing patterns like "a digit, repeated exactly ten times" or "a letter, followed by any number of letters or digits." Once you can write patterns instead of exact strings, an enormous number of everyday programming problems — validating a form field, cleaning messy data, searching logs, extracting structured information from free text — become a few lines of code instead of dozens of nested if statements.
From Exact Search to Pattern Search
Python's built-in string tools are exact-match tools. The in operator and str.find() only ever ask "does this literal sequence of characters appear?" Watch what happens when we try to use them for our phone-number problem:
text = "Please call me at 9876543210 after 5pm"
# This only finds THIS exact number
print("9876543210" in text) # True
# But this fails for a different valid number
text2 = "Please call me at 8123456780 after 5pm"
print("9876543210" in text2) # False -- even though there IS a phone number here!
The second line fails not because there's no phone number in text2 — there clearly is one — but because we hardcoded the wrong ten digits. What we actually want to ask is a question about structure: "is there a run of exactly ten digit characters anywhere in this text?" Ordinary string methods have no vocabulary for that question. Regular expressions do.
Your First Regex: Meeting Python's re Module
Python gives you regex power through the built-in re module. Its most-used function is re.search(), which scans a string looking for the first place a pattern matches:
import re
text = "Please call me at 9876543210 after 5pm"
match = re.search(r"\d{10}", text)
print(match)
print(match.group())
Output:
<re.Match object; span=(18, 28), match='9876543210'>
9876543210
Let's decode the pattern r"\d{10}" piece by piece, because every symbol here is a building block you will reuse constantly:
\dmeans "any single digit character" (0 through 9).{10}means "the thing right before me, repeated exactly 10 times."
Together, \d{10} means "ten digits in a row" — a description of shape, not a specific number. That's why it matched 9876543210 in our text: Python's regex engine walked through the string character by character, and starting at index 18 it found ten consecutive digit characters, which is exactly what the pattern demanded. The match.group() call retrieves the actual text that satisfied the pattern, and span=(18, 28) tells you it occupied index 18 up to (but not including) index 28 — ten characters, as required.
Notice the r before the string. That marks it as a raw string, and it matters more than it looks — we'll come back to exactly why later in this chapter, because getting this wrong causes one of the most common and confusing regex bugs beginners hit.
Character Classes: Matching a Category, Not One Character
\d is one example of a character class: a shorthand for "any character from this category." Python's regex engine supports several built-in ones:
\d— any digit (0-9)\D— any character that is NOT a digit\w— any "word character": a letter, digit, or underscore\W— any character that is NOT a word character\s— any whitespace (space, tab, newline).(a plain dot) — any character at all, except a newline by default
You can also build your own custom category using square brackets. [6-9] means "any single digit from 6 to 9." [aeiou] means "any single lowercase vowel." [A-Za-z] means "any single letter, upper or lower case." The dash inside brackets creates a range; outside brackets, a dash is just a literal dash character.
This matters for our phone-number problem because Indian mobile numbers have a real structural rule: by TRAI (Telecom Regulatory Authority of India) numbering convention, a 10-digit mobile number always starts with a digit from 6 to 9 — never 0 through 5. So \d{10} is actually too loose; it would happily "match" a random 10-digit number like 0123456789, which cannot be a real Indian mobile number. We can tighten the pattern using a character class for just the first digit:
pattern = r"[6-9]\d{9}"
Read left to right: "one digit from 6-9, followed by nine more digits of any value" — ten digits total, first one restricted. This is a small change with a big payoff: it now encodes a real-world rule about what counts as valid, not just "any ten digits."
Quantifiers: Saying How Many
{10} and {9} are examples of quantifiers — symbols that control how many times the preceding element must repeat. There are a few more you'll use constantly:
*— zero or more times+— one or more times?— zero or one time (i.e., "optional"){n}— exactly n times{n,m}— between n and m times (inclusive){n,}— n or more times
For example, a parent might type a country code before their number: +91 9876543210 or just 9876543210. The +91 part is optional. We can express that with ?:
pattern = r"(\+91[\s-]?)?[6-9]\d{9}"
Here (\+91[\s-]?)? says: "the group '+91 followed by an optional space or dash' may appear zero or one time." Note that \+ uses a backslash before the plus sign — that's because + is itself a special quantifier character in regex, so to match a literal plus sign, you must "escape" it with a backslash. This is a general rule: any symbol that has special regex meaning (. * + ? ^ $ ( ) [ ] { } | \) must be escaped with a backslash if you want to match it literally.
A subtlety worth knowing at this stage: quantifiers are greedy by default. They grab as much text as they possibly can, then give back characters only if forced to. This is easiest to see with a concrete example:
text = '"Delhi" and "Mumbai"'
greedy = re.search(r'".+"', text)
print(greedy.group()) # "Delhi" and "Mumbai"
lazy = re.search(r'".+?"', text)
print(lazy.group()) # "Delhi"
With ".+", the engine's .+ greedily swallows everything after the first quote — including the second city's quotes — then only backtracks the bare minimum needed to still end on a closing ", which happens to be the very last character of the string. Adding a ? right after the quantifier (+?) makes it "lazy": it grabs the smallest possible amount of text and stops as soon as the rest of the pattern is satisfied, so it stops at the very first closing quote it meets. Both are valid tools; you choose based on whether you want the widest or narrowest match.
Anchors: Pinning Down Position
So far our patterns can match a phone-number-shaped chunk anywhere inside a larger string, which is exactly what you want when searching through free-form text. But sometimes you want to validate that an entire input — say, a single form field — is nothing but a valid phone number, with nothing extra before or after. For that you need anchors:
^— matches the start of the string$— matches the end of the string
Wrapping a pattern in ^...$ forces it to describe the whole string, not just a piece of it:
pattern = r"^[6-9]\d{9}$"
print(bool(re.search(pattern, "9876543210"))) # True -- exactly 10 valid digits
print(bool(re.search(pattern, "98765432100"))) # False -- 11 digits, extra one after
print(bool(re.search(pattern, "987654321"))) # False -- only 9 digits
print(bool(re.search(pattern, "5876543210"))) # False -- starts with 5, not 6-9
Let's trace the second case carefully, because "why does one extra digit break it?" trips up a lot of beginners. The string is 98765432100 (11 characters). The engine tries to match ^ at position 0 (success, start of string). Then [6-9] consumes the 9. Then \d{9} greedily consumes the next nine digits: 876543210. That's 1 + 9 = 10 characters consumed, landing the engine at position 10, where the character 0 still remains. Now the pattern demands $ — the end of string — but position 10 is not the end; there's one more character left. The match fails. This is precisely why anchoring matters: without ^...$, re.search would happily report a match on the first 10 digits of an 11-digit string and call it "valid," which would be a real bug in a form validator.
How the Regex Engine Actually Scans a String
It helps to build an accurate mental model of what re.search() is doing mechanically. Without an anchor, it does not magically know where a match starts — it tries the pattern starting at position 0; if that fails, it tries starting at position 1; then position 2; and so on, until it either finds a starting position where the whole pattern matches, or it runs out of string.
Two things to take from this diagram: first, re.search() does not check every possible starting position even when it looks like it should — the moment position 5 succeeds, it stops and returns that match, ignoring the rest of the string entirely. Second, this is exactly why re.match() behaves differently from re.search(): re.match() only ever tries position 0. If position 0 fails, re.match() gives up immediately and returns None, even if a valid match exists later in the string:
text = "Her number is 9876543210"
print(re.match(r"\d{10}", text)) # None -- text doesn't START with a digit
print(re.search(r"\d{10}", text)) # finds it fine, mid-string
There is a third function, re.fullmatch(), which is the strictest of the three: it requires the pattern to account for the entire string, behaving like re.search() with an implicit ^ at the start and $ at the end built in automatically.
Groups: Pulling Pattern Pieces Apart
Parentheses ( ) in a regex do two jobs at once: they group parts of a pattern together (as we saw with the optional +91 earlier), and they mark that piece as a capturing group you can retrieve separately afterward. This is enormously useful when a match isn't just a yes/no question but has meaningful sub-parts.
Suppose you're processing a form field for date of birth, formatted DD-MM-YYYY:
text = "Date of birth: 15-08-1947"
pattern = r"(\d{2})-(\d{2})-(\d{4})"
match = re.search(pattern, text)
print(match.group(0)) # 15-08-1947 (the full match)
print(match.group(1)) # 15 (day)
print(match.group(2)) # 08 (month)
print(match.group(3)) # 1947 (year)
group(0) (or just group()) is always the entire matched text. Each numbered group after that corresponds to a parenthesized piece of the pattern, counted left to right by the position of the opening parenthesis. This lets you extract structured pieces — day, month, year — from a single pattern in one pass, rather than matching the whole date and then manually slicing the string apart.
Groups also power re.sub(), which performs find-and-replace using a pattern instead of an exact string. Indian banking apps commonly mask part of a phone number in SMS alerts for privacy — you can reproduce that behavior directly:
text = "My number is 9876543210, call me."
masked = re.sub(r"(\d{5})\d{5}", r"\1XXXXX", text)
print(masked)
# My number is 98765XXXXX, call me.
Here (\d{5}) captures the first five digits as group 1, and \d{5} (outside any group) matches the last five digits without capturing them. In the replacement string, \1 refers back to group 1's captured text, so the substitution reassembles "the first five real digits" plus five literal X characters, discarding the last five real digits entirely.
Finally, re.findall() returns every match in a string as a list, which is exactly the tool for our original admissions-helpdesk problem:
text = "Contact Raj at 9876543210 or Priya at 8123456780."
numbers = re.findall(r"[6-9]\d{9}", text)
print(numbers)
# ['9876543210', '8123456780']
Common Misconception: "Just Use a Normal String, Regex Doesn't Care"
Every example above used a raw string, written as r"pattern". Many students skip the r prefix because their pattern "seems to work fine" in testing — and then it silently breaks later. Here is exactly why the r matters, traced precisely:
Python string literals recognize certain backslash sequences as special characters before the string is ever handed to the regex engine. \n becomes a newline, \t becomes a tab — and \b becomes a backspace character (the same as pressing the Backspace key). This has nothing to do with regex; it's plain Python string parsing.
Now, in regex syntax, \b means something completely different: a "word boundary" — the invisible edge between a word character and a non-word character, useful for matching whole words. Compare:
bad_pattern = "\bcat\b" # NOT a raw string
good_pattern = r"\bcat\b" # raw string
print(len(bad_pattern)) # 5 -- backspace, c, a, t, backspace
print(len(good_pattern)) # 7 -- backslash, b, c, a, t, backslash, b
text = "the cat sat on the mat"
print(re.search(bad_pattern, text)) # None -- it's literally hunting for backspace bytes
print(re.search(good_pattern, text)) # <re.Match object; span=(4, 7), match='cat'>
bad_pattern is only 5 characters long because Python already converted both \b sequences into actual backspace bytes before the regex engine ever saw the string. The regex engine has no idea you meant "word boundary" — it's obediently searching the text for two literal backspace characters surrounding the word "cat," which don't exist anywhere in ordinary text, so the search fails silently (no error, just None). good_pattern, being raw, passes the literal two-character sequence \b straight through to the regex engine untouched, which correctly interprets it as a word-boundary anchor and finds "cat" as a whole word.
The general rule: always write regex patterns as raw strings, without exception. It costs you one character (r) and prevents this entire category of silent, hard-to-diagnose bug. Sequences like \d happen to "work" without r only because \d isn't a recognized Python string escape, so Python leaves it alone by coincidence (modern Python even warns you about this with a SyntaxWarning) — but \b, \n, \t, and a handful of others are recognized escapes, and they will quietly corrupt your pattern.
Putting It Together: Patterns You'll Actually Use
With character classes, quantifiers, anchors, and groups, you can now build validators for real structured data:
# Indian PIN code: 6 digits, first digit never 0
pin_pattern = r"^[1-9]\d{5}$"
print(bool(re.search(pin_pattern, "560001"))) # True (a Bengaluru PIN)
print(bool(re.search(pin_pattern, "060001"))) # False (can't start with 0)
# Vehicle registration plate: e.g. KA05MH1234
plate_pattern = r"^[A-Z]{2}\d{2}[A-Z]{1,2}\d{4}$"
print(bool(re.search(plate_pattern, "KA05MH1234"))) # True
# A basic school-email check
email_pattern = r"^[\w.+-]+@[\w.-]+\.[a-zA-Z]{2,}$"
print(bool(re.search(email_pattern, "student@ourschool.edu.in"))) # True
print(bool(re.search(email_pattern, "not an email"))) # False
Trace the plate pattern once to be sure you can read it fluently: ^ anchors at the start; [A-Z]{2} demands exactly two uppercase letters (the state code, "KA" for Karnataka); \d{2} demands two digits (the RTO district code, "05"); [A-Z]{1,2} allows one or two letters (the series code, "MH"); \d{4} demands four digits (the unique number, "1234"); $ anchors at the end so nothing extra sneaks in. Every symbol in the pattern maps to a real, named part of the plate's format — that mapping from "real-world structure" to "regex symbols" is the entire skill of writing good patterns.
This same skill resurfaces later in CBSE Computer Science when you clean messy real-world data using pandas — extracting years from date columns, validating IDs, splitting combined fields. The regex vocabulary you've built here (classes, quantifiers, anchors, groups) transfers directly; only the surrounding library code changes.
Test Yourself
Work these out on paper before checking the answer — predicting regex behavior correctly, without running code, is the real skill being tested here.
- What does
re.search(r"^\d+$", "1234a")return, and why?
Answer: None. The string contains a letter 'a' at the end, so after \d+ greedily consumes "1234", the $ anchor fails because there's still an 'a' left before the actual end of string. - Write a pattern that matches an Indian PIN code where you don't care what the digits are, just that there are exactly 6 of them (no first-digit restriction).
Answer: r"^\d{6}$" - Given
pattern = r"(\w+)@(\w+)"andtext = "raj@school", what ismatch.group(2)?
Answer: "school" -- it's the second parenthesized group, matching the word characters after the @. - Why would
"\t\d+"(without therprefix) behave unexpectedly as a regex pattern, but"\d+"alone would happen to work fine?
Answer: \t is a recognized Python escape (tab character), so it gets silently converted before reaching the regex engine, changing the pattern's meaning; \d is not a recognized Python escape, so it passes through unchanged by coincidence. - Between
r"a.*b"andr"a.*?b"on the text"a1b2b", which one matches more characters, and what does each return?
Answer: r"a.*b" is greedy and matches "a1b2b" (the whole string, backtracking only to the last b); r"a.*?b" is lazy and matches "a1b" (stopping at the first b it can).
Summary
A regular expression describes the shape of text rather than one exact value, which is what lets a single pattern validate or extract data across countless different inputs. You build patterns from four kinds of pieces: character classes (\d, \w, \s, [6-9]) that describe categories of characters; quantifiers (*, +, ?, {n,m}) that describe repetition; anchors (^, $) that pin a match to the start or end of a string; and groups (( )) that both structure a pattern and let you extract or reuse specific pieces of a match. Python's re module exposes this through re.search() (find anywhere), re.match() (only at the start), re.fullmatch() (the entire string), re.findall() (every match as a list), and re.sub() (pattern-based replace). Always write patterns as raw strings (r"...") so Python's own string-escaping rules don't silently corrupt characters like \b, \n, or \t before your regex engine ever sees them. With these pieces, you can turn "find anything that looks like a phone number, PIN code, or vehicle plate" from a tangle of string-slicing logic into a single, precise, readable pattern.
Practice Exercises
Now it is time to practice! Complete these challenges to solidify your understanding:
- Exercise 1: Write a short program that demonstrates the core concept from this chapter. Test it with at least 3 different inputs.
- Exercise 2: Find a real-world example where regular expressions: pattern matching power is used in an Indian company (like TCS, Infosys, Flipkart, or ISRO). Write a paragraph explaining the connection.
- Exercise 3: Create a mind-map connecting regular expressions: pattern matching power to at least 3 other topics you have studied.