A Python string carries dozens of built-in methods. They cover the day-to-day work you'll do on text: cleaning up user input, searching for substrings, replacing characters, splitting an address into parts, joining items into a single line. This lesson groups those methods into families so you can pick the right tool fast, and it shows the gotchas that trip people up when methods look almost identical but behave differently.
One rule applies to every method below: strings are immutable, so every method that "changes" a string actually returns a brand new string. The original is untouched. For now, just know that name.upper() doesn't change name, it gives you back a new uppercase string that you have to assign somewhere.
User input is messy. A customer types "john@example.com", another types "JOHN@Example.COM", a third types " John@Example.Com ". Before you can compare them, search for them in a database, or use them as a dictionary key, you need a normalized form. Case methods are how you get there.
Each call returns a fresh string. The variable email still holds "John@Example.COM" after all five calls. If you want to keep the lowercase version, you have to assign it: email = email.lower().
.title() capitalizes the first letter of every "word", where a word is a run of letters separated by non-letters. That's why it capitalizes the E in Example and the C in Com. It works for product names but breaks on apostrophes, so "o'brien".title() gives "O'Brien" (correct) but "don't".title() gives "Don'T" (wrong). Use it only when you trust the input.
.capitalize() is different: it uppercases the first character of the whole string and lowercases everything else. That's why the E in Example and the C in Com came back as lowercase. Useful for normalizing a single product name where you want exactly one capital letter at the start.
.swapcase() flips the case of every letter. It's rarely useful in production code, but you'll see it in puzzles and string-manipulation interview questions.
There's one more method in this family worth knowing about: .casefold(). It looks like .lower() but is more aggressive when it comes to non-ASCII characters. The German letter ß lowercases to itself, but casefolds to "ss", which is the form you actually want for case-insensitive comparison. For any text outside plain English, .casefold() is the safer choice for normalizing before a comparison.
A summary of the case methods:
| Method | Returns | Mutates? | Example |
|---|---|---|---|
.lower() | Lowercased copy | No | "ABC".lower() is "abc" |
.upper() | Uppercased copy | No | "abc".upper() is "ABC" |
.title() | Title-cased copy | No | "hello world".title() is "Hello World" |
.capitalize() | First char up, rest lower | No | "hELLO".capitalize() is "Hello" |
.swapcase() | Each letter's case flipped | No | "AbC".swapcase() is "aBc" |
.casefold() | Aggressive lowercase | No | "STRAßE".casefold() is "strasse" |
The most common cleanup task on user input is stripping whitespace. A customer types their email into a form, hits paste, and accidentally grabs a leading space. Now " alice@shop.com" won't match "alice@shop.com" in your database lookup, and you'll send back a "user not found" error for no good reason. The strip family fixes this in one call.
.strip() removes whitespace from both ends. .lstrip() only from the left, .rstrip() only from the right. None of them touch whitespace in the middle, so " hello world ".strip() gives "hello world" with the inner spaces intact.
Whitespace here means more than just the space character. It includes tabs (\t), newlines (\n), carriage returns (\r), and a few other Unicode whitespace characters. That covers most of what shows up when someone copies and pastes.
All the leading newlines, tabs, and trailing carriage returns are gone in one shot.
The strip methods also accept an argument: a string of characters to strip. This is where people get burned. The argument is treated as a set of characters, not as a substring to remove.
What's wrong with this code?
The reader expected "product". Instead they got "roduct", with the leading p chewed off. That's because .strip(".png") doesn't strip the substring ".png". It strips any character that appears in the set {".", "p", "n", "g"} from both ends, repeatedly, until it hits a character not in the set. So it removed the trailing g, n, p, ., then started on the front and removed the leading p too.
If you want to remove a specific suffix, use .removesuffix() (added in Python 3.9):
.removesuffix() and .removeprefix() only remove the exact substring if it's there, and return the original string unchanged if it isn't. They're the right tool whenever you mean "trim this exact ending" rather than "trim any of these characters".
The strip-with-argument form is useful when you really do want to strip a set of characters. Trimming punctuation, for example:
Every leading and trailing !, ., or ? gets removed. The interior text is left alone.
| Method | What it strips | Example |
|---|---|---|
.strip() | Whitespace from both ends | " hi ".strip() is "hi" |
.lstrip() | Whitespace from left only | " hi ".lstrip() is "hi " |
.rstrip() | Whitespace from right only | " hi ".rstrip() is " hi" |
.strip("xyz") | Any char in the set, both ends | "xyhixy".strip("xy") is "hi" |
.removeprefix("...") | Exact prefix substring | "a-b".removeprefix("a-") is "b" |
.removesuffix("...") | Exact suffix substring | "a.txt".removesuffix(".txt") is "a" |
Cost: All of these allocate a new string. For a one-off strip on user input, that's fine. If you're stripping millions of strings in a loop, the allocations add up; consider whether you can avoid the work entirely (for example, by validating input before storing).
Searching is the next big family. You want to know whether one string contains another, where it appears, how many times it appears, or whether it starts or ends with something specific. Python gives you a method for each.
The simplest check is the in operator, which returns a boolean:
That's the answer to "does it contain". For "where does it appear", use .find() or .index():
.find() returns the index of the first occurrence, or -1 if the substring isn't there. The -1 sentinel is the source of an entire category of bugs. People write if product.find("Bluetooth"): and forget that 0 (a valid index) is falsy in Python. If "Bluetooth" appeared at the very start, the if would be skipped. The right pattern is to compare explicitly:
.index() does the same search but raises ValueError if the substring is missing, instead of returning -1:
So .find() is for "I might not find it, give me a sentinel." .index() is for "I expect this to be here, complain loudly if it isn't." Pick based on whether absence is normal (find) or exceptional (index).
.rfind() and .rindex() are the right-hand counterparts: they return the index of the last occurrence, scanning from the right. Handy when you want the position of the final separator, like the last dot in a filename:
The first dot is at index 7, the last dot at index 22. To get the file extension you'd use .rfind(".") and then slice from there.
To count how often a substring appears, use .count():
.count() is case-sensitive. To count all variants regardless of case, lowercase first. Note that .count() finds non-overlapping occurrences, scanning left to right. For example, "aaaa".count("aa") returns 2, not 3, because after matching the first "aa" it picks up at index 2.
Cost: .find(), .index(), .count(), and in all scan the string. The cost is roughly proportional to the length of the string and the substring (worst case, length of string times length of pattern). For everyday strings this is invisible. For huge documents, consider whether you can index or pre-process once instead of searching repeatedly.
.startswith() and .endswith() answer the boolean questions "does this start with X" and "does this end with X":
The hidden-gem feature here is that both methods accept a tuple of options, and return True if any of them match. This is way cleaner than chaining or:
One call covers any number of allowed extensions. Use this whenever you find yourself writing name.endswith(".pdf") or name.endswith(".png") or name.endswith(...).
The same trick works for prefixes:
A summary of the search family:
| Method | Returns | If not found | Example |
|---|---|---|---|
in | bool | False | "abc" in "xabcx" is True |
.find(sub) | First index | -1 | "hello".find("l") is 2 |
.rfind(sub) | Last index | -1 | "hello".rfind("l") is 3 |
.index(sub) | First index | Raises ValueError | "hello".index("l") is 2 |
.rindex(sub) | Last index | Raises ValueError | "hello".rindex("l") is 3 |
.count(sub) | Number of occurrences | 0 | "banana".count("a") is 3 |
.startswith(s) | bool (accepts tuple) | False | "hello".startswith("he") is True |
.endswith(s) | bool (accepts tuple) | False | "hello".endswith("lo") is True |
The most-used method in this family is .replace(). It returns a new string with every occurrence of one substring swapped for another:
By default .replace() swaps every occurrence. If you only want to swap the first N matches, pass a third argument:
The count argument is useful for surgical edits where you know exactly how many to change.
.replace() is also the standard tool for stripping unwanted characters out of a string entirely. Replace with the empty string and they vanish:
Three chained replacements clear out the formatting characters. Each .replace() allocates a new string, so for a single short input this chained form is fine. For heavy cleanup work, the next method is more efficient.
Cost: Each .replace() call scans the entire string and allocates a new one. Chaining four replaces on a one-megabyte string does roughly four megabytes of work. For light cleanup this is fine; for bulk work, see .translate() below.
.translate() paired with str.maketrans() does multiple character substitutions in a single pass. The maketrans() helper builds a translation table mapping characters to characters (or to None, which deletes them):
The first two arguments to maketrans() are paired character mappings (we don't need any here, so they're empty). The third argument is a string of characters to delete. The resulting table tells .translate() "drop every (, ), space, and -". One pass through the string handles all four deletions.
You can also use .translate() for character substitutions. Build the table from two equal-length strings, where each character in the first maps to the character at the same index in the second:
Every vowel got uppercased in one pass. For a single character class like vowels this is overkill (you could use a regex or a comprehension), but for transliteration tasks (like normalizing accented characters) it's a fast, clean approach.
| Method | Purpose | Returns |
|---|---|---|
.replace(old, new) | Replace all occurrences | New string |
.replace(old, new, count) | Replace first count occurrences | New string |
.translate(table) | Map or delete characters using a table | New string |
str.maketrans(...) | Build a translation table for .translate() | Dict-like table |
Here's a small flow showing a common normalization pipeline: take a messy product name from user input, and turn it into a clean URL slug.
In code:
Method chaining like this is everyday Python. Each call returns a new string, and the next method is invoked on that result. The original raw_name stays exactly as it was.
Splitting and joining are how you move between strings and lists. Splitting takes one string and breaks it into a list of pieces. Joining takes a list of pieces and stitches them back into one string. Together they handle most CSV-like text processing without ever needing a regex.
.split() breaks a string at every occurrence of a separator:
The separator can be any string, not just a single character. That's why ", " (comma plus space) cleanly splits the address into its four parts.
If you call .split() with no argument, it does something special: it splits on any run of whitespace, and ignores leading and trailing whitespace entirely:
The two calls give very different results. .split() with no argument is the right call for parsing words out of natural text. .split(" ") is literal: every single space becomes a delimiter, so consecutive spaces produce empty strings, and leading/trailing spaces produce empty strings too. Beginners reach for .split(" ") thinking it's the same thing. It almost never is.
You can also limit the number of splits with the maxsplit argument:
maxsplit=1 gives you "head" and "everything else", which is great for parsing log lines or Key: Value headers. maxsplit=2 gives you the first two pieces and the rest as one string.
.rsplit() works the same way but counts from the right. With no maxsplit it produces the same result as .split(). The difference shows up only when you cap the split count:
.rsplit("/", 1) is the standard pattern for separating a path into "directory" and "filename" without writing index math.
.splitlines() is the right way to break text into a list of lines. It handles every line ending Python knows about (\n, \r\n, \r, plus a few exotic Unicode line separators) without you having to think about it:
.split("\n") doesn't know about Windows-style \r\n, so it leaves a stray \r on the second line. .splitlines() handles all line-ending styles in one call. Always prefer it for text that came from a file or the network.
By default .splitlines() drops the line endings from each line. Pass keepends=True if you want them preserved:
.join() is the inverse of .split(). It takes an iterable of strings and stitches them together with a separator. The method is called on the separator, not on the list, which trips up people who are new to Python:
The separator goes between elements, never at the start or end. So ", ".join(["a", "b", "c"]) gives "a, b, c", not "a, b, c, ".
.join() only works on iterables of strings. Pass it a list of integers and you'll get a TypeError:
The fix is a generator expression that converts each item to a string first:
Cost: .split() allocates a new list and a new string for every piece. For a megabyte of CSV with ten thousand rows, that's ten thousand small string objects plus a list. Usually this is fine; if you're processing huge files line-by-line, iterate over the file object instead of reading it all and splitting.
.partition() and .rpartition() are a different take on splitting. They always return a 3-tuple: the part before the separator, the separator itself, and the part after. If the separator isn't found, the first part contains the whole string and the other two are empty.
.partition("@") cleanly splits an email into local part and domain. .rpartition(".") pulls the file extension off the end. The fixed three-tuple shape makes unpacking convenient:
The _ is a Python convention for "I don't care about this value". Here we don't need the separator string, just the parts on either side.
| Method | Returns | Notes |
|---|---|---|
.split(sep) | List of pieces | No-arg form splits on any whitespace, drops empties |
.rsplit(sep, maxsplit) | List of pieces | Counts splits from the right |
.splitlines() | List of lines | Handles \n, \r\n, \r |
sep.join(iterable) | One joined string | All items in iterable must be strings |
.partition(sep) | 3-tuple (before, sep, after) | Splits on first occurrence only |
.rpartition(sep) | 3-tuple (before, sep, after) | Splits on last occurrence only |
Predicate methods return a boolean answer to a question about the entire string: "is every character a digit", "is every character a letter", "is the whole thing whitespace". They're the right tool when you want to validate input cheaply, before reaching for a regex or a try/except.
A common job is checking whether a string of digits really only contains digits, before you try to convert it to an integer:
.isdigit() is True only if the string is non-empty and every character is a digit. The empty string returns False, which matches the intuition: "is every character a digit" is vacuously satisfiable, but Python chose to return False to make the method useful as a guard.
There are three closely related predicates that all answer "is this digits": .isdigit(), .isnumeric(), and .isdecimal(). They differ in what they accept beyond plain ASCII digits:
| Method | Accepts | Example of what's True for this but False for the others |
|---|---|---|
.isdecimal() | Only decimal digit characters (0-9 and equivalents in other scripts) | "٠١٢" (Arabic-Indic digits) |
.isdigit() | All .isdecimal() plus things like superscripts | "²" (superscript 2) |
.isnumeric() | All .isdigit() plus fractions and Roman numerals | "½", "Ⅻ" |
For e-commerce work where you want to validate a quantity, stock count, or zip code, .isdecimal() is the safest choice because it only accepts characters that int() can actually parse. .isdigit() will say True for "²" even though int("²") raises ValueError. For everyday English-only input the three behave identically; the differences only matter when you handle text from other scripts.
.isalpha() is true only when every character is a letter:
The space in "Alice 1" and the digit both knock it out of "all letters". A typical check for a customer's first name would use .isalpha() after you've stripped whitespace.
.isalnum() widens to letters and digits, both ASCII and Unicode:
The space and the underscore both make .isalnum() return False. Useful for validating product codes that should be alphanumeric only.
.isspace() is True only when the string is non-empty and every character is whitespace. Handy for detecting "the user just hit space and submitted":
For case predicates, .isupper() and .islower() ignore characters that have no case (digits, punctuation, spaces) and check the cased characters:
The third example fails because of the lowercase letters in "Hello". The fourth fails because "123" has no cased characters at all, so the method returns False (it needs at least one cased character to evaluate).
Cost: Predicate methods scan the whole string. They short-circuit on the first failing character, so for a long string with a non-matching character early on, they return fast. They never allocate a new string, which makes them noticeably cheaper than calling .strip() followed by an equality check.
A summary of the predicates worth remembering:
| Method | Returns True when | Empty string returns |
|---|---|---|
.isdecimal() | Every char is a decimal digit | False |
.isdigit() | Every char is a digit (including superscripts) | False |
.isnumeric() | Every char is numeric (digits, fractions, Roman) | False |
.isalpha() | Every char is a letter | False |
.isalnum() | Every char is a letter or digit | False |
.isspace() | Every char is whitespace | False |
.isupper() | All cased chars are uppercase, at least one cased | False |
.islower() | All cased chars are lowercase, at least one cased | False |
Every method in this lesson returns a brand new string. None of them change the original. This is a recurring source of bugs for people new to Python:
name.upper() produced the string "ALICE", but nothing caught it, so it was discarded. To actually update name, you have to assign:
This isn't an oversight in the language; it's the deliberate design of strings as immutable objects. The rule of thumb is simple: if a string method seems to "do nothing", you probably forgot to assign the result.
10 quizzes