skip to content

Immutability and Concatenation Cost

Strings never change in place, so every method returns a new object and += in a loop can quietly go quadratic. Interviewers expect you to reach for ''.join() and to say why it is different.

part ofPythonoverview, primer and where to startread it →
on this pageshow

questions

3

Why does calling s.upper() on a Python string leave s unchanged?

level: juniorimportance: must knowfreq 72%

answer

  1. The object never changes, only the name
  2. Methods answer questions, they do not command
  3. No item assignment on this type
  4. Assign the result or lose it
  5. Stable value is what makes it hashable

basics

~20 s

Python str objects are immutable, so no method can change one in place. str.upper() builds and returns a brand-new string and leaves the original untouched, which is why you must assign the result: s = s.upper().

solid answer

~40 s

A `str` is a fixed sequence of code points; once built it can never be altered. That is why `str` has no mutating API at all — `upper()`, `strip()`, `replace()` and slicing each allocate and return a **new** string, and `s[0] = 'A'` raises `TypeError: 'str' object does not support item assignment`. The usual beginner bug is calling `s.strip()` on a line by itself and expecting `s` to change; you have to rebind with `s = s.strip()`. Rebinding is not mutation: any other name still pointing at the old string keeps seeing the old value, unlike a `list`, where `append` is visible through every alias. Immutability is what makes strings hashable and therefore usable as `dict` keys and `set` members, and safe to share without defensive copies.

code

python · 13 lines
python
s = "audit"
print(s.upper())      # AUDIT
print(s)              # audit  -- unchanged
print(s.replace("a", "A"))  # Audit
print(s)              # audit  -- still unchanged

s = s.upper()         # rebinding is how you keep the result
print(s)              # AUDIT

try:
    s[0] = "a"
except TypeError as exc:
    print(type(exc).__name__, exc)

go deeper

for a junior

Recall the one-liner: strings are immutable, so every method returns a new string and you must assign it. Be able to say what s[0] = 'A' raises and to spot a discarded s.strip() in review.

for a middle

Explain the mechanics: rebinding a name versus mutating an object, why aliases of a string never see a change while aliases of a list do, and why += on a str is an assignment rather than an edit.

for a senior

Show you have felt the cost. Point out where repeated transformations on large text allocate full copies, and name the accumulation tools — a list plus join, io.StringIO, bytearray for octets — you switch to in hot paths.

for a principal

Own the design argument: immutability buys hashability, lock-free sharing across threads and constant folding, at the price of copying on every transform. Be ready to say when a codebase should standardise on builders instead of ad-hoc concatenation.

## What immutability actually means here An immutable object is one whose value is fixed for its whole lifetime. A Python `str` holds a sequence of Unicode code points that is decided when the object is created and can never afterwards be edited. This is a property of the *object*, not of the *name* that refers to it — you can freely point the name `s` at a different string, and beginners often mistake that rebinding for mutation. The consequence is visible in the type's API: `str` offers nothing that changes a string. There is no `append`, no `insert`, no in-place `sort`. Item assignment fails because `str` implements no assignment hook for subscripts, so `s[0] = 'A'` raises `TypeError: 'str' object does not support item assignment`, and `del s[0]` fails for the same reason. ## Every method returns a new object Each method that looks like a transformation is really a constructor. `upper()`, `lower()`, `casefold()`, `strip()`, `replace()`, `center()`, `zfill()`, `expandtabs()`, `title()` — all of them allocate a fresh string and hand it back. So does slicing: `s[1:]` is a new object, not a window onto the old one. Even `encode()` follows the rule, returning a new `bytes` object rather than converting anything in place. This produces the single most common early Python bug: ```python name = " ada " name.strip() # result computed, then thrown away print(repr(name)) # ' ada ' — nothing happened name = name.strip() # the fix: rebind ``` A linter will usually flag the discarded expression, but the mental model matters more than the tool: a `str` method call is a *question*, not a *command*. If you do not keep the answer, you have done nothing but burn a little CPU. ## Rebinding versus mutating The difference between rebinding and mutating shows up as soon as two names share an object: ```python a = "log" b = a a = a.upper() # a is rebound to a new object; b still sees the old one # a == 'LOG', b == 'log' xs = ["log"] ys = xs xs.append("line") # mutation: ys sees it too # ys == ['log', 'line'] ``` That is also why `s += 'x'` does not disprove immutability. Augmented assignment on a `str` computes a new value and stores it back under the same name; the old string object is simply left unreferenced and freed. The name changed, the object did not. ## Why the language chose this Three reasons come up in interviews. **Hashability.** A hash-based container places a key in a bucket derived from its hash value. If the key could change after insertion its hash would change and it would be unfindable. Because a `str` can never change, it has a stable hash (CPython even caches it on the object), so strings are the default key type for dictionaries, the members of sets, and legal in any position that demands hashability. **Safe sharing.** An object nobody can modify can be passed anywhere, stored in any structure, captured by any closure and read from any thread without a defensive copy and without aliasing bugs. Half the reason strings are pleasant to work with in Python is that you never have to ask who else holds a reference. **Optimization headroom.** Because two equal strings are interchangeable, the compiler can fold literal expressions at compile time and the runtime can share a single object for identical literals and identifier-like strings. None of that is safe for a mutable type. ## The cost you accept in exchange Immutability is not free: every transformation copies the entire string. One `replace()` on a large document allocates a second document. Chain four transformations and you have allocated four full copies, of which three are immediately garbage. For one-off work that is irrelevant; inside a loop it is the origin of the classic quadratic-concatenation problem, and it is why Python gives you dedicated accumulation tools instead. ## What to use when you genuinely need to edit When you must build or revise text incrementally, do not fight the type — swap it out. Collect the pieces in a `list` and call `"".join(pieces)` once at the end; write through an `io.StringIO` when the code is shaped like a series of writes; and when the data is octets rather than text, use a `bytearray`, which is the mutable sibling of the equally-immutable `bytes`. For a single edit, rebuilding from slices is perfectly idiomatic: `s = s[:i] + ch + s[i + 1:]`. In an interview, the answer that lands is the two-part one: strings are immutable so every method returns a new object, and therefore you assign the result — followed by naming the accumulation tools you reach for when copying would be wasteful.

  • If Python strings are immutable, why does `s += 'x'` appear to work?
    Because augmented assignment on a `str` is not mutation. It computes a new string of the combined value and stores it back under the same name; the previous object is left unreferenced. Any other name still bound to the old string continues to see the old value, which is the giveaway that nothing was edited in place.
  • Why does a Python string's immutability matter for using it as a dict key?
    A hash container puts a key in a bucket chosen from its hash. If the key's value could change after insertion, its hash would change and the entry would become unfindable. Because a `str` can never change, its hash is stable — CPython caches it on the object — so strings are the natural key type for `dict` and member type for `set`.
  • Which built-in gives you a mutable stand-in when you really must edit text?
    There is no mutable `str`. Use a `list` of pieces and `"".join(...)` once at the end, or an `io.StringIO` when the code is a sequence of `write()` calls. If the data is octets rather than text, `bytearray` is the mutable counterpart of `bytes`. Convert back once, at the end.

A string is a printed page, not a whiteboard. upper() does not rewrite the page; it prints you a new one in capitals and hands it over. If you throw that new page away, the original is still exactly as it was.

saying these in an interview costs you the question

  • Believes str.upper() edits the string in place
  • Calls s.strip() on its own line and never assigns it
  • Expects s[0] = 'A' to work on a str
  • Argues += proves Python strings are mutable
  • Thinks immutability means the name cannot be rebound
  • Assumes slicing a str returns a view, not a copy

context

open as a page

Why is `s += part` in a Python loop unsafe to rely on for speed?

level: middleimportance: must knowfreq 60%

basics

~20 s

CPython sometimes resizes the left-hand string in place when the loop variable holds the only reference, so the loop looks linear. That is an unguaranteed implementation detail: add one more reference and every iteration copies everything accumulated so far.

open as a page

When do you reach for io.StringIO instead of ''.join() to build a large Python string?

level: seniorimportance: should knowfreq 38%

basics

~20 s

Use ''.join() when every fragment can be collected in a list first — it allocates the result once. Use io.StringIO when the text comes from scattered write() calls, or when you must rewind and discard a half-written section.

open as a page