bigonotes
DSA: Python & HacksPython Internals

Strings

How Python stores strings, what each string operation costs, why += in a loop is slow, and the methods you reach for in interviews.

6 min readLast updated Python Internals

Strings show up in a huge share of interview problems: anagrams, palindromes, parsing, sliding windows. Python strings are immutable, compactly stored objects. Knowing how they're stored tells you which operations are cheap, which copy, and why one innocent-looking loop can turn O(n)O(n) code into O(n2)O(n^2).

How CPython stores a string#

Immutable. Once created, a str never changes. s.upper(), s.replace(...) and s + t all return a new string. Immutability is what makes strings safe to share between functions and usable as dict keys.

Fixed width per string (PEP 393). CPython picks the smallest width that fits the widest character in that string, and uses it for every character:

Widest character in the string Bytes per character
Latin-1 (U+0000 to U+00FF), e.g. plain ASCII 1
Up to U+FFFF, e.g. most CJK text 2
Beyond U+FFFF, e.g. emoji like 🐍 4

So appending one emoji to 1,000 ASCII characters gives a new string of about 4,000 data bytes, not 1,004. The pay-off is O(1)O(1) indexing: with uniform width, s[i] is just start + i * width. Mixed widths would force a scan from the start.

Length and hash are stored. len(s) reads a stored size field, so it's O(1)O(1). The hash is computed the first time it's needed (for example, the first dict lookup) and cached in the object, so later lookups with the same string object skip rehashing.

Interning. CPython keeps one shared copy of some strings, mainly identifier-like literals ("hello_world") and names used in code. That lets the interpreter compare names by address. You can force it with sys.intern(s). It is an optimisation, not a guarantee, so compare strings with ==, never is.

What operations cost#

Here is what common string operations cost for a string of length nn:

Operation Cost Note
len(s), s[i] O(1)O(1) stored size, fixed-width chars
s[a:b] O(b−a)O(b-a) slicing copies into a new string
s + t O(n+m)O(n+m) always a new string
"".join(parts) O(total length)O(\text{total length}) one allocation
s == t O(n)O(n) may exit early on mismatch
sub in s, s.find(sub) about O(n)O(n) typical can be worse for adversarial patterns
hash(s) O(n)O(n) once, then cached
s[::-1], s.lower(), s.split() O(n)O(n) each returns new objects
sorted(s) O(nlog⁡n)O(n \log n) returns a list of chars

The += trap and the join fix#

Since strings can't grow, result += ch builds a brand-new string and copies everything so far into it, every iteration. Building a string of length nn that way copies about n2/2n^2/2 characters.

Appending one character at a time allocates a new, longer string each step, while join measures the total once and allocates a single string

"".join(parts) scans the parts once to compute the total length, allocates once, then copies each piece straight into place: O(n)O(n).

words = ["a"] * 5

# Slow pattern: new string each iteration
result = ""
for w in words:
    result += w

# Fast pattern: collect in a list, join once
parts = []
for w in words:
    parts.append(w)        # amortised O(1)
fast = "".join(parts)

print(result == fast)      # True

CPython sometimes resizes a string in place for s += t when nothing else references s, so the slow loop may look fine on your laptop. That's an implementation detail other interpreters don't share. In interviews, use a list plus join, and say why.

Quick check#

Test yourself on the ideas above.

Question Answer
What does sub = s[0:5] do in memory? Allocates a new string and copies 5 characters. It is not a view into s.
What is the time complexity of len(s)? O(1)O(1): the length is stored in the object.
When is a string's hash computed? The first time it's needed, then cached in the object.
Why can a string be a dict key but a list can't? The string can't change, so its hash can't change either.

Method reference#

Case conversion#

Method What it does Example → Result
s.lower() all lowercase "AbC".lower() → "abc"
s.upper() all uppercase "AbC".upper() → "ABC"
s.capitalize() first char upper, rest lower "hELLO".capitalize() → "Hello"
s.title() first letter of each word upper "hi there".title() → "Hi There"
s.swapcase() flip each char's case "AbC".swapcase() → "aBc"
s.casefold() aggressive lowercase for comparisons "ß".casefold() → "ss"

Searching#

Method What it does Example → Result
s.find(sub) index of first match, -1 if absent "abcabc".find("c") → 2
s.rfind(sub) index of last match, -1 if absent "abcabc".rfind("c") → 5
s.index(sub) like find but raises ValueError if absent "abc".index("b") → 1
s.rindex(sub) last match, raises if absent "abcb".rindex("b") → 3
s.count(sub) non-overlapping occurrences "aaa".count("aa") → 1
sub in s membership "bc" in "abc" → True

Checking (return bool)#

Method What it does Example → Result
s.startswith(pre) begins with prefix "abc".startswith("ab") → True
s.endswith(suf) ends with suffix "abc".endswith("c") → True
s.isdigit() all chars are digits "123".isdigit() → True
s.isalpha() all chars are letters "abc".isalpha() → True
s.isalnum() all letters or digits "a1".isalnum() → True
s.isspace() all whitespace " ".isspace() → True
s.islower() / s.isupper() all cased chars lower / upper "abc".islower() → True
s.isnumeric() all numeric, incl. Unicode "½".isnumeric() → True

Trimming#

Method What it does Example → Result
s.strip() remove leading and trailing whitespace " hi ".strip() → "hi"
s.lstrip() / s.rstrip() left only / right only " hi".lstrip() → "hi"
s.strip(chars) remove any of the given chars from both ends "xxhixx".strip("x") → "hi"

Splitting and joining#

Method What it does Example → Result
s.split() split on runs of whitespace "a b c".split() → ["a","b","c"]
s.split(sep) split on an exact separator "a,b,c".split(",") → ["a","b","c"]
s.split(sep, n) split at most n times "a,b,c".split(",", 1) → ["a","b,c"]
s.rsplit(sep, n) split from the right "a,b,c".rsplit(",", 1) → ["a,b","c"]
s.splitlines() split on line breaks "a\nb".splitlines() → ["a","b"]
sep.join(items) join strings with a separator ",".join(["a","b"]) → "a,b"

Replacing and padding#

Method What it does Example → Result
s.replace(old, new) replace all occurrences "aaa".replace("a","b") → "bbb"
s.replace(old, new, n) replace the first n "aaa".replace("a","b",2) → "bba"
s.zfill(w) pad left with zeros "42".zfill(5) → "00042"
s.ljust(w, c) / s.rjust(w, c) pad right / left to width "hi".ljust(5,"*") → "hi***"
s.center(w, c) pad both sides "hi".center(6,"*") → "**hi**"

Partitioning#

Method What it does Example → Result
s.partition(sep) (before, sep, after) at first match "a=b=c".partition("=") → ("a","=","b=c")
s.rpartition(sep) same, at last match "a=b=c".rpartition("=") → ("a=b","=","c")

Interview idioms#

These one-liners come up again and again in string problems.

from collections import Counter

s = "listen"
print("".join(sorted(s)))   # 'eilnst': anagram signature
print(s[::-1])              # 'netsil': reversed copy
print(ord("c") - ord("a"))  # 2: char -> 0..25 index for count arrays
print(chr(ord("a") + 3))    # 'd': index -> char
print(Counter(s)["l"])      # 1: character frequencies
chars = list(s)             # mutable copy for in-place style edits
chars[0] = "L"
print("".join(chars))       # 'Listen'

Gotchas#

Watch out for these common string mistakes.

  • Every "modifying" method returns a new string. s.strip() on its own line does nothing useful; write s = s.strip().
  • find vs index: find returns -1 when absent, index raises. Watch for if s.find(x):, which is falsy when the match is at index 0.
  • split() vs split(" "): bare split() collapses runs of whitespace and drops leading/trailing space. "a b".split() → ["a","b"], but "a b".split(" ") → ["a","","b"].
  • Slices copy. In a loop, s[i:] costs O(n)O(n) each time. Pass indices to recursive helpers instead of slicing.
  • String hashes vary between runs (hash randomisation), so never rely on hash(s) being stable across processes.

Key takeaways#

  • Strings are immutable; every change makes a new object.
  • Each string uses 1, 2 or 4 bytes per char depending on its widest char, which keeps indexing O(1)O(1).
  • len is O(1)O(1), slicing and concatenation copy, and the hash is cached after first use.
  • Build strings with a list and "".join, not += in a loop.
  • Compare strings with ==; interning is an implementation detail.

Found a mistake? Tell me on LinkedIn ↗

Further reading#