Run it
Object identities before and after a "modification", then the cost of ignoring what that implies — measured at three sizes so the shape of the curve is visible rather than asserted.
The one real cost is building strings incrementally:
At 10,000 words the first version performs roughly 50 million character copies. join computes the total length, allocates once, and copies each piece in.
This is the most common Python performance mistake, and the reason it survives review is that the quadratic version is fine on ten words and catastrophic on ten thousand.
Two related points:
CPython has an optimisation that sometimes makes += in a loop appear linear: if a string has exactly one reference, it can be resized in place. It is an implementation detail, it fails as soon as anything else holds a reference, and it does not exist in other Python implementations. Do not rely on it.
io.StringIO is the alternative when the pieces are produced incrementally and cannot be collected in a list first — it behaves like a writable buffer.
What to use instead when mutation is needed
| Need | Use |
|---|
| Build a string from pieces | A list, then "".join() |
| Incremental writes | io.StringIO |
| Mutable bytes | bytearray |
| Character-level editing | A list of characters, join at the end |
| Templating | f-strings or str.format |
| Repeated replacement | str.translate with a mapping table |
bytearray is the genuinely mutable relative:
Note the pairing: str is immutable and bytes is immutable, while bytearray is the mutable byte sequence. There is no mutable str in Python, and a list of one-character strings is the usual stand-in.
What immutability actually means
There is no operation in Python that changes a string in place. s.upper(), s.replace(), s.strip() and s + t all return a new string and leave the original exactly as it was. Assigning to an index — s[0] = 'A' — is not slow, it is a TypeError.
This is enforced, not advisory. There is no private method and no escape hatch, which is what lets the interpreter make assumptions it could not otherwise make.
The three things it buys
Hashability. A dictionary stores a key in a slot chosen from its hash. If the key could change after insertion, its hash would no longer match its slot and the entry would become unreachable. Immutable types can be keys; mutable ones cannot, which is why {[1,2]: 'x'} raises and {(1,2): 'x'} does not.
Free sharing. Passing a string to a function, storing it in two places, closing over it — none of these need a defensive copy, because no one can modify it behind your back. In a language with mutable strings, library code often copies on the way in just in case.
Interning. CPython reuses one object for short string literals that look like identifiers, so equality can often be settled by an identity check. That is an optimisation immutability makes legal.
What it costs, and the one place it bites
Every edit allocates. Usually that is irrelevant — one replace on one line costs nothing you can measure. It matters in exactly one shape: building a string a piece at a time in a loop.
Each += copies everything accumulated so far, so n appends copy 1 + 2 + 3 + ... + n characters, which is O(n²). The fix is "".join(parts): one pass to total the lengths, one allocation, one copy. The editor below measures both.
The follow-up you should expect
"If strings are immutable, why does s += 'x' sometimes look fast?" CPython has a special case that resizes a string in place when the target is a plain local variable and nothing else refers to it. It is real, it is invisible, and it stops firing the moment you store into an attribute or a list. Do not build on it.
Interning and the is trap
Because strings are immutable, the interpreter may share one object for equal strings. That makes is behave unpredictably for strings:
Short identifier-like literals are interned automatically at compile time. Strings built at runtime generally are not.
The rule that follows: use == for string comparison, always. is asks whether two names refer to the same object, which is almost never the question. Relying on interning produces code that works in testing and fails on computed input.
sys.intern() forces interning explicitly, which is occasionally worth it when the same strings are compared millions of times — identity comparison is faster than character comparison. That is a genuine optimisation in parsers and interpreters, and rare in application code.
Immutability elsewhere in Python
| Type | Mutable | Hashable |
|---|
str | No | Yes |
bytes | No | Yes |
bytearray | Yes | No |
tuple | No | Yes, if its contents are |
frozenset | No | Yes |
list | Yes | No |
dict | Yes | No |
set | Yes | No |
The pattern is consistent: mutable types are unhashable, for exactly the same reason strings must be immutable to be keys.
The tuple row has a subtlety worth knowing: a tuple is immutable, and if it contains a list it is unhashable, because its hash would depend on something that can change.
How other languages compare
Java strings are immutable, for the same reasons, with StringBuilder as the mutable builder — the direct analogue of using a list and joining.
C++ std::string is mutable, so in-place modification and reversal are possible — which is why "reverse a string in place" is a C++ question that does not translate to Python.
JavaScript strings are immutable, and it has no separate builder type; array join is the idiom.
Rust distinguishes &str (an immutable borrowed slice) from String (an owned, growable buffer) in the type system.
The convergence is informative: most modern languages chose immutable strings, because the hashing, sharing and thread-safety benefits outweigh the cost of a separate builder for the one case where mutation is wanted.
Questions people ask
Does s += x create a new string? Yes, in general. CPython sometimes resizes in place when there is exactly one reference, and that is an implementation detail to ignore.
Why can strings be dictionary keys but lists cannot? Keys must have a stable hash, which requires immutability.
How do I modify a string then? Build a new one — slicing and concatenation, or a list of characters joined at the end.
Is bytearray the mutable string? It is the mutable bytes type. There is no mutable str.
Why is a is b sometimes True for equal strings? Interning shares identical literals. Never depend on it; use ==.
Does immutability cost memory? Sometimes more allocations, and sometimes less through sharing and interning. The predictability is usually worth more than either.
Recap in one screen
- Strings cannot be modified; every "change" produces a new object.
- Immutability is what makes them hashable, safely shareable, internable and thread-safe.
- The cost is that
+= in a loop is O(n²) — use a list and "".join(). bytearray is the mutable byte sequence; there is no mutable str.- Interning makes
is unreliable for strings; compare with ==.