Strings in Python cannot be changed. Not "changing a string is awkward" but "changing a string is impossible", which is a stronger statement than most languages make. It explains why s[0] = 'H' raises an error, why s += '!' can surprise you, why strings work as dictionary keys, and why building a long string by appending in a loop is dramatically slower than it looks like it should be. All of it falls out of one design decision.
What does immutable actually mean in practice?
Every operation that looks like it edits a string actually creates a new one and gives you a reference to it. The name you used points at the new object; the old object is untouched, and it stays alive as long as anything still refers to it.
text = 'hello'
print('text ->', text)
print('id before ->', id(text))
text += ' world'
print('text after ->', text)
print('id after ->', id(text))
try:
text[0] = 'H'
except TypeError as error:
print('assignment ->', error)
original = 'hello'
alias = original
alias += '!'
print('original ->', original)
print('alias ->', alias)
print('ids differ ->', id(original) != id(alias))
The last four lines are the important ones. alias and original started out pointing at the same object, so alias += '!' could have changed it in place and mutated both names. Because strings are immutable, the operation built a new string and moved only alias to point at it. original is still 'hello', exactly as it was.
This is the same rule you met with lists in the variables lesson, applied in the opposite direction. A list mutation changes the shared object; a string operation replaces the reference.
Why does Python choose immutability?
Because it removes a whole category of bug. If a value cannot change, then it is safe to hand the same value to several places at once. Nobody has to wonder whether the code on the other side of a function call modified it while you were using it.
Three concrete benefits follow.
- Sharing is free and safe. A string can be passed to five functions and stored in a cache without any of them being able to surprise the others.
- Strings are hashable. A hashable value can be a dictionary key or a set member, because its hash never changes. If a string could be edited in place, every dictionary containing it would silently break.
- Sharing across threads is safe. Two threads reading the same string cannot interfere with each other, because neither can write to it.
There is a practical consequence beyond safety. Because a string's contents never change, Python can compute its hash once and cache it, which is why using long strings as dictionary keys is cheap. It is also why the language can share one copy of a repeated literal between several places in a program instead of storing it again and again.
import sys
first = 'hello'
second = 'hello'
print('equal strings ->', first == second)
print('same object? ->', first is second)
key = 'user-42'
records = {key: 'Ada'}
print('lookup by key ->', records['user-42'])
print('hashes agree ->', hash(key) == hash('user-42'))
shared = sys.intern('tutorsbot')
print('interned twice ->', shared is sys.intern('tutorsbot'))
The is result in the second line is true because the compiler stores each string literal once per code object, so both names refer to the same stored value. The interning call at the end does deliberately: it asks Python to keep exactly one copy of a string in a global table so that identical strings compare identical by identity. You will rarely need it, but it is the mechanism behind a surprising number of equality checks being fast.
What is the performance cost of immutable strings?
Here is where immutability stops being an abstract virtue and starts costing you. Because += has to create a new string, appending to a string inside a loop copies the whole thing every single time. Ten thousand appends means about fifty million character copies, and the work grows with the square of the final length.
import timeit
def build_with_plus(pieces):
result = ''
for piece in pieces:
result += piece
return result
def build_with_join(pieces):
return ''.join(pieces)
pieces = ['block'] * 2000
plus_time = timeit.timeit(lambda: build_with_plus(pieces), number=5)
join_time = timeit.timeit(lambda: build_with_join(pieces), number=5)
print(f'+= loop : {plus_time:.4f}s')
print(f'join : {join_time:.4f}s')
print(f'ratio : {plus_time / join_time:.1f}x slower')
print('same result ->', build_with_plus(pieces) == build_with_join(pieces))
The printed timings are measured on your machine, so the exact numbers will differ, but the shape of the result does not: the loop that appends is dramatically slower, and the gap widens every time you double the number of pieces. join() collects the pieces first and allocates the result once, so its cost grows in proportion to the total size rather than to its square.
The rule is simple. One += on a string is fine and idiomatic. A += inside a loop over many pieces is a performance bug, and ''.join(pieces) is the fix.
How do you get mutable text when you need it?
Sometimes you genuinely want a buffer you can modify in place. Python gives you several, and each fits a different situation.
import io
characters = list('hello')
characters[0] = 'H'
print('list of chars ->', ''.join(characters))
buffer = bytearray(b'hello')
buffer[0] = ord('H')
print('bytearray ->', buffer.decode('utf-8'))
stream = io.StringIO()
stream.write('hello ')
stream.write('world')
stream.seek(0)
print('StringIO ->', stream.read())
A list of characters is the simplest choice, and joining it at the end gives you the string. A bytearray is the right choice for binary data that you need to edit in place, such as a chunk of a file or a protocol buffer. An io.StringIO object behaves like a file you can write to and read back, which makes it useful for building text incrementally without touching the disk.
Reach for these when you genuinely need mutation. For everything else, the immutable version is faster and safer.
Your turn: measure and fix a slow string builder
- Write
build_slow(pieces)that appends with+=in a loop, andbuild_fast(pieces)that usesjoin. Confirm that both return the same string for a list of a hundred words. - Time both with
timeitat three sizes: 100, 1000 and 5000 pieces. Print the ratios and note how the gap widens. - Confirm that the
+=version is quadratic by plotting, in plain text, how the time changes when you double the size. If doubling the input more than doubles the time, you have quadratic behaviour. - Write a third version that collects the pieces in a list and joins once, and check that it matches the others exactly, including for an empty input list.
- Finally, use
sys.getsizeof()on the result of a small join to see the final string's memory size, and explain in a comment why the+=version allocated far more memory along the way even though both end up with the same string.
Why immutability matters
- Strings never change, so every "edit" creates a new object and rebinds the name.
- That makes strings safe to share, safe to use as dictionary keys, and safe to pass between threads.
- Two identical literals are usually the same object, and
sys.intern()asks for that deliberately. - Appending in a loop copies the whole string each time, so build long strings with
join(). - Use a list, a
bytearrayor anio.StringIOwhen you genuinely need to edit text in place.