Why are Python strings immutable, and how does string interning affect memory?
Strings are immutable so they can be safely shared (interned), used as dict keys, and passed across threads without locks. Every operation that looks like mutation — slicing, concatenation, `.replace()` — returns a new string object. Interning means CPython may store only one copy of a string literal and reuse it, saving memory for repeated identifiers and short strings.
How to think about it
This isn’t trivia — immutability is exactly what lets strings be used as dict keys, shared between threads without locks, and cached aggressively. If a string could change after you’d used it as a key, that entry would become unreachable; interning wouldn’t be safe at all. So Python guarantees strings never change in place.
Every operation that looks like mutation actually builds a new string:
s = "hello"
s[0] = "H" # TypeError — item assignment isn't supported
s2 = s.upper() # a new string; s is untouched
s3 = s + " world" # another new string
The original s is never modified — and that’s the language’s promise, not just a CPython quirk.
A worked example — interning and identity
Because strings are immutable, CPython is free to store one copy of a literal and hand it out repeatedly. That’s interning, and it’s why is (identity) and == (value) can disagree:
import sys
# Short, identifier-like literals are interned -> the same object
a = "hello"
b = "hello"
print("short literal a is b :", a is b)
# A string BUILT at runtime is a distinct object, even when equal
c = "hello world"
d = "".join(["hello", " ", "world"])
print("runtime-built c is d :", c is d)
print("but c == d :", c == d) # value equality always holds
# sys.intern forces two equal strings to share one stored copy
e = sys.intern("hello world")
f = sys.intern("hello world")
print("after intern e is f :", e is f)
# Immutable -> hashable -> usable as keys
print("dict lookup :", {"hello": 1}["hello"])
print("hash stays constant :", hash("key") == hash("key"))
short literal a is b : True
runtime-built c is d : False
but c == d : True
after intern e is f : True
dict lookup : 1
hash stays constant : True
a is b is True because the short literal "hello" was interned to one object. c is d is False because d was assembled at runtime into a separate object — yet c == d is still True, the reminder that you compare strings with ==, never is. And sys.intern forces the runtime string to share the single stored copy.
Build strings with join, not += in a loop
Immutability has one performance consequence worth internalising: since += can’t mutate, it allocates a new string and copies all the previous characters every time. Concatenating n strings in a loop is therefore O(n²):
# Bad — O(n²): each += copies everything so far
result = ""
for word in words:
result += word + " "
# Good — O(n): join allocates once
result = " ".join(words)
For formatting, f-strings are the modern, fastest choice:
name, score = "Alice", 98.6
msg = f"{name} scored {score:.1f}" # compiled, fast and readable