PYTHON / STRINGS
Searching and replacing within strings
Locate substrings with in, find, rfind, index and count, and produce modified copies with replace, including limited and repeated passes.
What you will learn
- Use `in` for a yes/no test, `find`/`rfind` for a position, `index` when absence is a bug
- Check for -1 before slicing with a `find` result, since -1 is a valid negative index
- Remember `replace` returns a new string and swaps every non-overlapping match by default
- Pass start/end to `find` or a count to `replace` to limit the region or number of hits
Understanding Searching and replacing within strings
Searching a string comes in two flavours: asking whether something is there, and asking where it is. `"x" in s` answers the first question with a bool. `s.find(sub)` answers the second with the index where the match starts, or -1 if there is no match; `s.rfind(sub)` does the same scan from the right. `s.index(sub)` and `s.rindex(sub)` return the same positions but raise ValueError instead of returning -1, which is what you want when a missing substring means the data is malformed and you would rather crash than continue with a bogus number.
All of these methods accept optional start and end arguments that bound the search the same way slice bounds do, but the returned index is still relative to the whole string. That makes `i = s.find(sub, i + 1)` the standard way to walk through every occurrence: you keep the absolute positions and never copy the string. `s.count(sub)` uses the same left-to-right scan, and because it resumes after each match it reports non-overlapping hits only, so `"aaaa".count("aa")` is 2, not 3.
Replacing works on a different model entirely. Strings are immutable, so `s.replace(old, new)` cannot edit `s`; it builds and returns a brand new string, and you must assign it somewhere or the work is thrown away. By default every non-overlapping occurrence is replaced, and an optional third argument caps how many. Matching is literal and case-sensitive: `replace` knows nothing about patterns, word boundaries, or letter case, so `"The"` and `"the"` are unrelated targets and anything pattern-shaped belongs to the `re` module.
log = "GET /index.html 200 GET /about.html 404"
print("404" in log)
print(log.find("GET"))
print(log.find("GET", 4))
print(log.rfind("GET"))
print(log.find("POST"))
print(log.count("GET"))
cleaned = log.replace("GET", "HEAD", 1)
print(cleaned)
print(log)Search methods hand back positions into the original string (or -1), while replace never mutates anything and instead returns a new string with every literal match swapped.
Worked examples
Collecting every occurrence, overlaps included
Walks the string with find to gather all start positions and compares that with what count reports.
text = "banana bandana"
hits = []
i = text.find("ana")
while i != -1:
hits.append(i)
i = text.find("ana", i + 1)
print(hits)
print(text.count("ana"))Example explained
Line 1`text.find("ana")` returns 1, the index of the first character of the match, not the match itself.
Line 2Restarting at `i + 1` allows overlapping matches, so index 3 ("ana" sharing the 'a' at 3) is found too.
Line 3`count` resumes after the whole match instead, skipping the overlap and reporting only 2.
Line 4The loop ends when `find` returns -1, which is the sentinel meaning "not present".
Case-insensitive search on the original text
Searches a lowercased copy but slices the untouched original using the index it returns.
title = "The Hitchhiker's Guide"
print(title.find("the"))
print(title.lower().find("the"))
i = title.lower().find("guide")
print(title[i:i + 5])
print(title.replace("the", "a"))Example explained
Line 1`find("the")` fails on the capitalised "The" because matching is exact, byte for byte.
Line 2`title.lower()` is a separate string, but it has the same length, so its indexes line up with the original.
Line 3Slicing `title[i:i + 5]` recovers the original casing "Guide" from the index found in the lowered copy.
Line 4`replace("the", "a")` matches nothing and returns an unchanged copy rather than an error.
Limited replacement and repeated passes
Shows the count argument and why one replace call cannot collapse runs of repeated characters.
template = "Hello NAME, your NAME is ready"
print(template.replace("NAME", "Ada", 1))
path = "a/b//c///d"
print(path.replace("//", "/"))
while "//" in path:
path = path.replace("//", "/")
print(path)Example explained
Line 1The third argument 1 stops after the first match, leaving the second "NAME" alone.
Line 2One pass over "///" turns the first two slashes into one and leaves the third, producing "//" again.
Line 3The `while "//" in path` loop reruns the replacement until the membership test finally fails.
Line 4Each pass reassigns `path`; without the assignment the original string would never change.
Important notes
The empty string matches everywhere: `"abc".find("")` is 0 and `"abc".replace("", "-")` gives '-a-b-c-'.
To trim a known prefix or suffix use `removeprefix`/`removesuffix` (Python 3.9+); `replace` would also strip matches from the middle.
Common mistakes
Calling `s.replace("a", "b")` as a statement and expecting `s` to change; strings are immutable, so the new string is discarded and `s` still holds the old text.
Feeding an unchecked `find` result into a slice: when the substring is missing, `find` gives -1 and `s[-1:]` quietly returns the last character instead of failing.
Chaining replaces that feed into each other, as in `s.replace("a", "b").replace("b", "c")`, which turns the original 'a' characters into 'c' as well.
Try it yourself
Change, predict, then run
Given `line = "ERROR: disk full; ERROR code 28"`, print the index of the second "ERROR", print a copy where only the first "ERROR" becomes "WARN", and then print `line` to confirm it is unchanged.
Open the Python workspaceCheck your understanding
What does `s = "aaaa"; print(s.count("aa"), s.replace("aa", "b"))` print?
- 2 bb
- 3 bbb
- 3 bb
- 2 bab
Show answer
Both methods scan left to right and resume after the end of each match, so "aa" is found at index 0 and index 2 only: two matches, and the replacement yields "bb". Answers with 3 assume overlapping matches at indexes 0, 1 and 2, which Python's count and replace deliberately skip.