Searching for text, counting the matches and replacing what you found are the three jobs that every real program handling text eventually needs to do. Python gives you two layers for that work: plain string methods for literal substrings, and the re module for patterns. The confusing part is not the syntax but the difference in failure behaviour, because find() and index() do the same job and disagree about what happens when the text is not there.
How do I search for a substring?
Three tools, and the choice between them is about how you want failure to look. The in operator answers a yes-or-no question. find() tells you where the text starts, or returns -1 when it is missing. index() tells you the same position, but raises ValueError instead of returning -1.
text = 'the quick brown fox jumps over the lazy dog'
print('contains ->', 'fox' in text)
print('find ->', text.find('fox'))
print('find miss ->', text.find('cat'))
print('rfind ->', text.rfind('the'))
try:
text.index('cat')
except ValueError as error:
print('index miss ->', error)
print('count ->', text.count('the'))
print('startswith ->', text.startswith('the'))
print('endswith ->', text.endswith('dog'))
The -1 versus exception split is a design decision you have to make consciously. -1 is convenient but easy to forget to check: text.find('cat') + 1 silently produces 0 instead of failing. An exception is loud, and it is the better choice when a missing substring means the program is wrong rather than merely unlucky.
One more detail about count(): it counts non-overlapping occurrences. The string 'aaa' contains two occurrences of 'aa', not three.
The most common use of find() is not the position itself but the slice around it, which is how you pull a value out of a line of text.
line = 'user=ada; action=login'
start = line.find('user=') + len('user=')
end = line.find(';', start)
print('extracted ->', line[start:end])
Searching from the right works the same way with rfind() and rindex(), which is what you want for a path or a URL where the last occurrence is the interesting one. When a string may contain several different separators, re.split() handles them all at once, which the plain split() cannot do.
How do I replace text?
str.replace() replaces every occurrence of a literal substring. There is no count argument, so if you want to change only the first occurrence you need a different approach.
import re
text = '2026-10-27'
print('replace ->', text.replace('-', '/'))
print('delete ->', text.replace('-', ''))
print('no match ->', text.replace('x', 'y'))
print('sub once ->', re.sub(r'\d', '*', 'a1b2c3', count=1))
print('sub all ->', re.sub(r'\d', '*', 'a1b2c3'))
Replacing with the empty string deletes the text, which is the quickest way to strip characters out of a string. And when you need a limit, a pattern, or a computed replacement, re.sub() is the tool: it takes a pattern rather than a literal, and its count argument caps the number of replacements.
The replacement can also be a function, which is how you transform each match as it is found.
import re
prices = re.findall(r'\d+', 'order 12 and order 7')
print('found ->', prices)
print('doubled ->', re.sub(r'\d+', lambda m: str(int(m.group()) * 2), 'order 12 and order 7'))
print('comma sep ->', re.sub(r'\d+', lambda m: f'{int(m.group()):,}', 'total 1234567 units'))
When do you need regular expressions?
Reach for the re module when the thing you are looking for has structure rather than being a fixed string. Three functions cover most needs, and the difference between them is where they start matching.
re.match(pattern, text)matches only at the very beginning of the string.re.search(pattern, text)looks anywhere in the string and returns the first match.re.fullmatch(pattern, text)requires the whole string to match.
import re
pattern = r'\b\w+@\w+\.\w+\b'
text = 'write to [email protected] or [email protected] today'
found = re.search(pattern, text)
print('search ->', found.group() if found else 'no match')
print('match ->', re.match(r'\w+', text).group())
print('fullmatch ->', re.fullmatch(r'\w+', text))
print('findall ->', re.findall(pattern, text))
for found in re.finditer(pattern, text):
print('finditer ->', found.group(), 'at', found.span())
findall() returns a list of the matched text, while finditer() returns match objects that know where each match was, which is what you want when you need the surrounding context. A match object also carries group() for the whole match and groups() for any capture groups inside the pattern, plus start() and end() for the character offsets.
import re
text = 'Ada Lovelace, Grace Hopper, Alan Turing'
pattern = r'(?P<first>\w+) (?P<last>\w+)'
for match in re.finditer(pattern, text):
print(
f"{match.group('first'):<6} {match.group('last'):<8}"
f' all groups: {match.groups()}'
)
Named groups, written (?P<name>...), are worth the extra characters because they let you refer to a part of the match by name rather than by position, and the names show up in error messages when the pattern does not match what you expected.
Two warnings about regular expressions. The first is that they are slower than literal string methods, because the pattern has to be compiled and then matched, so a fixed substring that appears many times should be handled with str.find() or str.replace(). The second is that a pattern is much harder to review than a plain comparison, and patterns grow. A pattern with nested groups and lookaheads is usually better expressed as a little ordinary Python code, which is easier to read and far easier to test.
How do you search case-insensitively?
There are two answers, and they suit different situations. To compare two strings, fold both with casefold() and use ==. To search inside a larger text, pass the re.IGNORECASE flag. The flag is also how you switch on multi-line matching, dot-matches-newline behaviour and verbose patterns.
import re
text = 'Python is great. PYTHON is everywhere.'
print('ignorecase ->', len(re.findall('python', text, re.IGNORECASE)))
print('casefold ->', text.casefold().count('python'))
print('multiline ->', re.findall(r'^\w+', 'one\ntwo\nthree', re.MULTILINE))
needle = 'c++ (lang)'
haystack = 'we use c++ (lang) here'
print('escaped ->', re.findall(re.escape(needle), haystack))
The last line matters more than it looks. A regular expression treats characters like + and ( as syntax, so searching for a literal string containing them will either fail or match something unintended. re.escape() quotes the string so it is matched literally, and it is the right habit whenever the search text comes from a user.
Your turn: build a log scrubber
- Write a function that takes a log line and removes anything that looks like an email address, using
re.sub()with a replacement pattern. Check that the rest of the line survives. - Extend it to replace the digits in the line with asterisks, but only the first three, using the
countargument. Print both versions side by side. - Now find every timestamp in a line, using
re.findall()with a pattern for digits and colons, and print them as a list. - Add a case-insensitive search for a given word and compare the result with
text.casefold().count(word). Explain in a comment when the two answers differ. - Finally, write a version that redacts a word supplied by the user, making sure you call
re.escape()on it. Test with the wordc++and show that the pattern still matches literally.
Search and replace reference
inanswers a yes-or-no question,find()returns a position or-1, andindex()raisesValueError.count()counts non-overlapping occurrences.str.replace()replaces every occurrence; usere.sub()when you need a pattern, a limit, or a computed replacement.re.matchanchors at the start,re.searchlooks anywhere, andre.fullmatchrequires the whole string.- Use named groups for readability, and
re.escape()whenever the pattern comes from user input. - Compare with
casefold()and search with there.IGNORECASEflag.