String Methods

Strings are immutable, but they come with a large, well-designed toolkit of methods for splitting, joining, searching, and reshaping text without ever mutating the original.

Python's str type ships with dozens of built-in methods for common text-processing tasks: breaking text apart, stitching it back together, trimming whitespace, searching, replacing, and checking its shape. Because strings are immutable, every one of these methods returns a *new* string (or list of strings) rather than modifying the original in place — the object you called the method on is never changed.

Strings Are Immutable: Methods Return New Objects

Every string method returns a new str object; it never modifies the receiver. This matters for both correctness (you must capture the return value) and mental model (there is no 'in-place uppercase' for strings the way list.sort() mutates a list).

s = "hello"
s.upper()          # returns 'HELLO', but does not change s
print(s)            # still 'hello'

s = s.upper()        # must rebind to capture the result
print(s)             # 'HELLO'

Splitting: split(), rsplit(), splitlines()

str.split(sep=None, maxsplit=-1) breaks a string into a list of substrings. With no argument, it splits on any run of whitespace and discards leading/trailing empty pieces — the most common form for tokenizing loosely-formatted text. With an explicit separator, every occurrence splits, including consecutive ones (which produce empty strings between them). rsplit() splits from the right, useful with maxsplit to peel off a suffix. splitlines() splits on line boundaries without leaving the line-ending characters in the results.

"  a  b   c  ".split()          # ['a', 'b', 'c']
"a,b,,c".split(",")             # ['a', 'b', '', 'c']
"a.b.c.txt".rsplit(".", 1)      # ['a.b.c', 'txt']
"line1\nline2\r\nline3".splitlines()  # ['line1', 'line2', 'line3']

Joining: str.join()

separator.join(iterable) concatenates an iterable of strings, placing separator between each pair. It is a method on the separator (not the pieces) precisely because join needs to know the glue, and it is the idiomatic, efficient way to build a string from parts — far better than repeated += concatenation in a loop, which repeatedly allocates new strings.

str.join() requires every item in the iterable to already be a str; joining a list containing an int raises TypeError — convert first, e.g. ", ".join(str(n) for n in numbers).

words = ["fast", "readable", "pythonic"]
print(", ".join(words))        # 'fast, readable, pythonic'
print("".join(["a", "b", "c"]))  # 'abc'

# split and join are natural inverses for whitespace-normalizing text
text = "  too   much   space "
print(" ".join(text.split()))   # 'too much space'

Trimming Whitespace: strip(), lstrip(), rstrip()

strip() removes leading and trailing characters (whitespace by default) and returns the trimmed copy. lstrip()/rstrip() trim only the left or right side. Passing a string argument treats it as a *set* of characters to strip, not a substring — order doesn't matter and each end is stripped of characters until one not in the set is found.

"   padded   ".strip()          # 'padded'
"xxhelloxx".strip("x")           # 'hello'
"###title###".strip("#")         # 'title'
"---data---".lstrip("-")         # 'data---'

Searching: find(), index(), count(), in

find(sub) returns the lowest index where sub is found, or -1 if it isn't present — it never raises. index(sub) does the same search but raises ValueError if not found, which is often preferable since a silently-ignored -1 can be mistaken for a valid index. rfind()/rindex() search from the right. count(sub) returns the number of non-overlapping occurrences. For a simple membership check, prefer the in operator, which is both clearer and doesn't require handling a sentinel return value.

text = "the quick brown fox"
text.find("quick")     # 4
text.find("slow")      # -1
"quick" in text          # True (preferred for a yes/no check)

try:
    text.index("slow")
except ValueError:
    print("not found")

text.count("o")          # 2

Prefix/Suffix Checks: startswith(), endswith()

startswith() and endswith() check for a prefix or suffix and return a bool. Both accept a tuple of strings to check several options at once, which is clearer and faster than chaining multiple or conditions.

filename = "report_final.pdf"
filename.endswith(".pdf")                 # True
filename.endswith((".pdf", ".docx"))      # True
filename.startswith("report")              # True

Replacing: replace()

str.replace(old, new, count=-1) returns a copy with every occurrence of old replaced by new; an optional count limits how many replacements happen, scanning from the left.

"2024-01-15".replace("-", "/")          # '2024/01/15'
"aaa".replace("a", "b", 2)               # 'bba'

Case Conversion

upper(), lower(), title() (capitalizes the first letter of each word), capitalize() (capitalizes only the very first character, lowercasing the rest), and swapcase() cover the common transformations. casefold() is a more aggressive lowercasing intended for caseless comparisons across languages (it handles some Unicode cases lower() doesn't, such as the German 'ß').

"Hello World".upper()        # 'HELLO WORLD'
"Hello World".lower()        # 'hello world'
"hello world".title()        # 'Hello World'
"HELLO world".capitalize()   # 'Hello world'
"straße".casefold()          # 'strasse'

Partitioning: partition() and rpartition()

partition(sep) splits a string at the *first* occurrence of sep into exactly a 3-tuple: (before, sep, after). Unlike split(), it always returns three items, even when sep is absent (in which case after is empty and sep in the result is ""), which makes it convenient for unpacking without checking list length first. rpartition() splits at the last occurrence.

key, sep, value = "name=Ada".partition("=")
print(key, value)   # name Ada

"no-equals-here".partition("=")   # ('no-equals-here', '', '')

"a.b.c".rpartition(".")            # ('a.b', '.', 'c')

Validation Methods

A family of is*() methods report whether the *entire* string matches a category: isdigit()/isnumeric()/isdecimal() (numeric variants, with subtle differences for Unicode numerals), isalpha(), isalnum(), isspace(), isupper()/islower(), and isidentifier() (valid Python identifier). All return False on an empty string, since there's nothing to satisfy the condition.

Use str.isdigit() for a quick check before int() conversion, but remember it doesn't accept a leading sign or decimal point — int(user_input) inside a try/except ValueError is usually more robust than pre-validating with isdigit().

"12345".isdigit()      # True
"3.14".isdigit()        # False (the dot is not a digit)
"abc123".isalnum()      # True
"  ".isspace()           # True
"".isalpha()             # False -- empty string satisfies no category

Putting It Together: A Small Text-Processing Example

strip() removes outer whitespace, split(',') breaks into fields, and partition(':') on each field separates key from value -- a common pattern for parsing simple delimited text.

raw = "  Name: Ada Lovelace , Role: Engineer  "

fields = {}
for part in raw.strip().split(","):
    key, _, value = part.partition(":")
    fields[key.strip()] = value.strip()

print(fields)
# {'Name': 'Ada Lovelace', 'Role': 'Engineer'}

Key Takeaways

Every string method returns a new string; strings are never mutated in place — always capture the return value.

split()/join() are inverses for tokenizing and reassembling text; separator.join(iterable) is the efficient way to build a string from parts.

Prefer in for membership checks, find() when a not-found -1 is convenient, and index() when a missing substring should raise.

partition() always returns a 3-tuple, making unpacking safe even when the separator is absent.

Validation methods like isdigit()/isalpha() check the *whole* string and return False on empty input.

s.strip() print(s) # unchanged, whitespace still there s = s.strip() print(s)

String methods never mutate the original object; the result must be reassigned (or used directly) or it is simply discarded.

", ".join([1, 2, 3]) # TypeError ", ".join(str(n) for n in [1, 2, 3])

str.join() requires every element to already be a string. Non-string items must be converted first, commonly with a generator expression.

if text.find("foo"):... # runs even when foo is missing, since find() can return -1 (truthy) if "foo" in text:...

find() returns -1 when not found, and -1 is truthy in a boolean context, so treating its return value directly as a yes/no answer is a bug. Use in, or explicitly compare to -1.

"CamelCase".strip("Case") # strips any of the characters C,a,s,e -- not the substring 'Case'

"CamelCase".removesuffix("Case") # Python 3.9+: strips the exact substring

strip()'s argument is a set of characters, not a substring to remove — passing a multi-character string strips any combination of those characters from each end. Use removeprefix()/removesuffix() (3.9+) to remove an exact substring.

Why do string methods like upper() or strip() never modify the string they're called on?

Python's str type is immutable — once created, a string object's character data cannot change. Every method that appears to transform a string actually builds and returns a brand-new string object, leaving the original unchanged.

What is the difference between find() and index() when the substring is not present?

find() returns -1 and never raises; index() raises ValueError. Use index() when a missing substring is a bug you want surfaced immediately, and find() (or the in operator) when absence is an expected, handled case.

What does "a,b,,c".split(",") return, and why does it contain an empty string?

['a', 'b', '', 'c'] — split() with an explicit separator produces one field per gap between separators, including empty fields for two adjacent separators. This differs from a no-argument split(), which collapses runs of whitespace and drops empty pieces.

Why is ", ".join(words) generally preferred over building a string with repeated += in a loop?

Each += on a string creates a new string object and copies all previous characters into it, making a loop of N concatenations roughly O(n²) in the worst case for a general implementation. join() computes the total length once and builds the result in a single pass, which is both clearer and more efficient.

What does "key=value=extra".partition("=") return, and how does it differ from rpartition() here?

partition('=') splits at the *first* '=': ('key', '=', 'value=extra'). rpartition('=') splits at the *last* '=': ('key=value', '=', 'extra').

What does ''.isalpha() return, and why?

False — all the is*() validation methods require the string to be non-empty and every character to satisfy the condition; an empty string vacuously fails every such check.

Give an example where startswith() with a tuple argument is clearer than an equivalent or chain.

filename.endswith(('.jpg', '.png', '.gif')) is clearer and avoids repeating filename.endswith(...) three times, compared to filename.endswith('.jpg') or filename.endswith('.png') or filename.endswith('.gif').

Write a function normalize_whitespace(text) that collapses any run of whitespace in text into a single space and strips leading/trailing whitespace.

def normalize_whitespace(text: str) -> str: return " ".join(text.split()) print(normalize_whitespace(" too much space ")) # 'too much space'

text.split() with no argument splits on any run of whitespace and discards empty pieces, so ' '.join(...) on the result reassembles the words with single spaces -- a classic split/join idiom.

What does this code print?

'HELLO, PYTHON!' -- replace() first swaps 'World' for 'Python' (producing a new string), and upper() is chained on that result to uppercase everything. The original s is untouched.

Write a function count_words(text) that returns a dict mapping each lowercase word to the number of times it appears in text, ignoring punctuation attached via .strip() on common punctuation characters.

def count_words(text: str) -> dict[str, int]: counts: dict[str, int] = {} for raw_word in text.lower().split(): word = raw_word.strip(".,!?;:\"'") if word: counts[word] = counts.get(word, 0) + 1 return counts print(count_words("The cat sat. The cat ran!")) # {'the': 2, 'cat': 2, 'sat': 1, 'ran': 1} lower() normalizes case, split() tokenizes on whitespace, and strip() with a set of punctuation characters removes trailing/leading punctuation from each token before counting.

What does this print, and why does the result start with an empty string?

It prints ['', 'usr', 'local', 'bin']. Because the path starts with '/', splitting on '/' produces an empty field before the first separator -- split() always produces one field per gap, including a leading empty field when the string begins with the separator.

s = "Hello, World!"
result = s.replace("World", "Python").upper()
print(result)
path = "/usr/local/bin"
print(path.split("/"))