AlgoMaster Logo

String Operations

High Priority29 min readUpdated June 6, 2026
Listen to this chapter
Unlock Audio

Strings carry a lot of built-in behavior: they can be glued together, repeated, searched, tested for numeric content, case-changed, and trimmed. This lesson covers those operations in depth. The examples use product names, customer input, order IDs, and search queries.

Concatenation: + and join

The + operator glues two strings end-to-end, returning a new string:

Three strings concatenated: the first name, a single space, the last name. Both operands of + must be strings; Python does not convert a number. Mixing types raises TypeError.

For two or three pieces, + works fine. The problem appears when building a long string out of many pieces in a loop:

This works, but it's slow at scale. Strings are immutable, so each += doesn't append in place. It allocates a brand-new string, copies the entire existing content into it, then copies the new piece on the end. For five names the cost is negligible. For fifty thousand log lines or a CSV row built from a thousand cells, the quadratic cost stacks up.

The standard fix is str.join(), which builds the final string in a single pass. Call it on the separator and pass an iterable of strings:

One allocation, no intermediate copies. The separator goes between elements, not at the end, which is the usual goal. To join non-strings, convert them first using a generator expression:

s += piece inside a loop is O(n²) in the total length, because each step copies everything seen so far. "".join(parts) is O(n). For a few items, either works. For thousands, use join.

The top path shows += in a loop: every iteration allocates a fresh string and copies everything from the previous step. The bottom path shows join: Python walks the iterable once to compute the total size, allocates that buffer, then copies each piece into place. Same result, different performance characteristics.

Repetition with *

Multiplying a string by an integer repeats it that many times:

Order doesn't matter: 30 * "-" produces the same string as "-" * 30. Both forms are valid. The other operand must be an int, not a float; "abc" * 2.5 raises TypeError.

A zero or negative multiplier gives an empty string instead of an error:

repr is used here so the empty strings are visible. The "negative gives empty" behavior matters when a quantity turns negative through bad input upstream: the result is an empty divider rather than a crash.

A use of + and * together for a quick banner:

For anything more elaborate, the f-string format spec handles alignment and padding more cleanly. For a horizontal rule or a column of stars, * is the simpler form.

Membership: in and not in

in returns True if the substring on the left appears anywhere in the string on the right:

The match doesn't have to align with word boundaries. "phones" matches because those characters appear inside "Headphones". in is case-sensitive: "bluetooth" (lowercase) would not match. For case-insensitive checks, lowercase both sides first.

in answers the simple "does this contain that" question. For position, count, or replacement, use one of the methods below (find, index, count, replace). if s.find(sub) != -1 is verbose; if sub in s reads cleaner.

Comparison: Lexicographic Order

The comparison operators (<, <=, ==, !=, >, >=) all work on strings. They compare character by character based on Unicode code point values:

The first comparison stops at the first character: 'a' < 'b', so "apple" < "banana". The second goes deeper: both start with 'a', then 'p', then they differ at the third character ('p' vs 'r'), and 'p' < 'r'.

The third and fifth comparisons reveal an important detail. Uppercase letters have lower code points than lowercase ones in ASCII. 'A' is 65, 'a' is 97, 'Z' is 90. So "Apple" < "apple" is True, and "Z" < "a" is also True. This is the lexicographic order Python uses, and it does not match human alphabetical order:

All the capitalized words come before all the lowercase ones, because uppercase code points are smaller. For human-friendly alphabetical sorting, pass a key function that normalizes case:

str.lower is used as the key, so the comparison happens on the lowercased version of each string. The original case in the list is preserved; only the comparison sees the lowercase version.

For sorting user-visible names with proper locale handling (Spanish ñ, German ß, accented characters), use the locale module or a library like PyICU. The default lexicographic order is fine for sorting order IDs or hashes; it's not a substitute for true alphabetical sorting in a UI.

Length: len

len(s) returns the number of characters in the string:

len counts every character, including spaces and punctuation. It's O(1) because Python stores the length of every string when it's created; there's no walking through the characters to count them. Calling len(s) repeatedly inside a loop is cheap.

For ASCII text the character count matches the byte count, but for strings with accented characters or characters from other writing systems the two diverge. "café" has length 4 (four characters) but encodes to 5 bytes in UTF-8 because é takes two bytes.

Iteration: for char in s

A string is a sequence, so a for loop walks through its characters left to right:

Each iteration binds the loop variable to one character. There's no separate "character" type in Python; each iteration's value is a string of length one. To also get the index, use enumerate:

This is cleaner than writing a range(len(s)) loop and indexing manually. Iteration combines naturally with the other string operations.

A common use is counting characters that match some condition. The compact form uses sum with a generator expression:

sum adds booleans as 1 and 0. The expression c.isdigit() returns True for digit characters and False otherwise, so summing across the string counts the matches in one pass.

Reversal: s[::-1]

To get a reversed copy of a string, use the slicing form s[::-1].

The slice [::-1] means "from start to end, step -1", which walks the string backwards. This pattern shows up often enough to treat as a fixed idiom.

reversed(s) returns an iterator (not a string). It's useful for iterating the characters in reverse without building a new string:

For the reversed string itself, "".join(reversed(product)) works but product[::-1] is shorter and faster. Use reversed only when an iterator is required (memory savings on very long strings, or when feeding it to another iterator-aware function).

Checking Properties

Every string has a family of is... methods that ask "does this string consist entirely of characters from a particular category". They all return True for the matching string and False otherwise. They also all return False for the empty string.

MethodReturns True when every character isEmpty string
isdigit()A digit (0-9 and some unicode digit characters)False
isalpha()A letterFalse
isalnum()A letter or digitFalse
isspace()Whitespace (space, tab, newline, etc.)False
isupper()An uppercase letter (must have at least one letter)False
islower()A lowercase letter (must have at least one letter)False
istitle()Title-cased (first letter of each word upper, rest lower)False

Several methods in action:

"12.45".isdigit() is False because of the period. "ORD 2024".isalnum() is False because of the space. "Aarav Mehta" is title-cased: first letter of each word is upper, the rest are lower. "aarav mehta" isn't.

A common (and incorrect) use is checking whether a string is a valid integer with isdigit. It works for unsigned positive integers, but it returns False for negatives because '-' isn't a digit:

For a reliable "is this convertible to an int" check, try the conversion and catch the exception:

int() accepts an optional leading sign and rejects floats, which matches the practical meaning of "is this an integer string". The exception-based form is the idiomatic Python approach.

Case Operations

Strings have six methods for changing case:

MethodWhat it doesExampleResult
upper()All uppercase"Hello".upper()"HELLO"
lower()All lowercase"Hello".lower()"hello"
title()First letter of each word upper, rest lower"new arrival".title()"New Arrival"
capitalize()First letter upper, rest lower"new ARRIVAL".capitalize()"New arrival"
swapcase()Upper becomes lower, lower becomes upper"Hello".swapcase()"hELLO"
casefold()Aggressive lowercase for comparison"ß".casefold()"ss"

A practical example: case-insensitive search by normalizing both sides:

Both the query and each title get lowercased before the in check, so the comparison is effectively case-insensitive. For most English text, .lower() is enough. For text with mixed scripts or special-cased characters like German ß, .casefold() is the more correct choice because it handles cases where one character lowercases to multiple. "Straße".casefold() is "strasse", while "Straße".lower() is "straße".

s.lower() allocates a new string. In a tight loop comparing many strings against a constant query, lowercase the query once before the loop, not on every iteration.

A common bug pattern: forgetting that case methods return new strings instead of mutating:

What's wrong with this code?

product_name.title() returns a new string but the result is thrown away. Strings are immutable; methods like title, lower, and upper never modify the original.

Fix:

Assign the result back to the variable (or to a new name) to retain it.

Searching: find, index, count, startswith, endswith

Once a substring's presence is confirmed (in), the next questions are usually "where" and "how many times". Five methods cover those.

find(sub) returns the lowest index of the first occurrence of sub, or -1 if sub isn't present:

The -1 return allows checks without exceptions: if email.find("@") >= 0. For the simple "is it there" question, if "@" in email reads better.

index(sub) is like find but raises ValueError when the substring isn't present:

Use find when "not present" is a normal case to handle with a value. Use index when "not present" indicates a bug and should raise. The choice mirrors the one between dict.get(key) and dict[key].

count(sub) returns the number of non-overlapping occurrences:

Non-overlapping matters for tricky cases. "aaaa".count("aa") is 2, not 3, because Python finds the first aa (positions 0-1), then starts the next search at position 2 and finds another aa (positions 2-3). It doesn't double-count.

startswith(prefix) and endswith(suffix) are boolean checks for the ends of the string:

Both methods accept a tuple of prefixes or suffixes and return True if any of them matches:

This is cleaner than chaining or conditions. It's the standard form for checking whether a filename is in a known set of extensions or whether an order ID belongs to one of several prefixes.

Replacing: replace

replace(old, new) returns a new string with every occurrence of old replaced by new:

Replacing something that isn't present is fine; the method returns the original string unchanged. To replace only the first N occurrences, pass a third integer argument:

Only the first two occurrences become 1; the rest stay as one.

A common use is normalizing user input before validation: strip the dashes or spaces a customer typed into a phone number or order ID:

Two replace calls in sequence: first remove spaces, then remove dashes. Both return new strings, so chaining works naturally.

replace is case-sensitive. For case-insensitive replacement, either lowercase both sides (which loses the original case) or use the re module's re.sub with re.IGNORECASE.

Each replace call walks the string once and allocates a new one. Chaining several replace calls walks the string several times. For a single pass with multiple substitutions, str.translate (with str.maketrans) does the work in O(n) regardless of how many character substitutions stack up.

Stripping Whitespace: strip, lstrip, rstrip

Input from a customer or a line read from a file almost always needs leading and trailing whitespace removed before processing. The three strip methods do this:

MethodRemoves fromExample
strip()Both ends" Mouse ".strip() is "Mouse"
lstrip()Left end only" Mouse ".lstrip() is "Mouse "
rstrip()Right end only" Mouse ".rstrip() is " Mouse"

A practical use:

strip() removes spaces, tabs, newlines, and other whitespace characters from both ends. The middle of the string is untouched. The original customer_email is unchanged because, like all string methods, strip returns a new string.

By default the strip methods remove whitespace. A string argument specifies which characters to strip instead. Important: the argument is a set of characters, not a substring to remove:

The first call strips # from both ends. The second call has surprising behavior: rstrip(".gz") strips any combination of ., g, and z from the right end, which produces report.csv for this input but would also strip report.csv.gzgzgzgg down to report.csv.. For substring suffix removal, Python 3.9 added removesuffix (and removeprefix), which removes the exact substring if it's there:

removesuffix returns the original string unchanged if the suffix isn't present. Use it for cleanly chopping file extensions, ID prefixes, or trailing markers. Older code often misused rstrip for the same purpose before 3.9 made the proper version available.

A common bug is forgetting that strip takes a character set, not a substring:

What's wrong with this code?

This happened to produce the expected output for the input above. A different input shows the bug:

lstrip("https://") removes any leading characters that are in the set {'h', 't', 'p', 's', ':', '/'}. The leading h of hello matches, then e doesn't, so stripping stops. But https://h strips down to e, and then e doesn't match. Both hs got eaten. Use removeprefix("https://") for proper prefix removal.

Best Practices Summary

A short list of habits that pay off:

  • Prefer `join` over `+=` for building strings in loops. O(n) versus O(n²). For small numbers of pieces, either works.
  • Use f-strings for building strings out of values. f"{name}: ${price:.2f}" is faster and clearer than concatenation.
  • Use `in` first, then `find` / `index` / `count` when position or counts are needed. if "@" in email reads better than if email.find("@") >= 0.
  • Lowercase both sides for case-insensitive comparison, or use `casefold()` for stronger normalization. Don't compare with case mismatched.
  • Use `removeprefix` / `removesuffix` (3.9+) for exact prefix/suffix removal. lstrip and rstrip with arguments strip character sets, not substrings.
  • Remember immutability. Methods return new strings. Assign the result back to retain it. The original is never modified.
  • `strip()` with no argument removes whitespace. `strip(chars)` replaces the default with the given character set. A common pitfall.
  • Pass tuples to `startswith` and `endswith` for multiple-extension checks. filename.endswith((".pdf", ".doc")) is cleaner than or-chains.

Quiz

String Operations Quiz

10 quizzes