67.8 String Concatenation Performance: join() vs +=
Right, let’s settle this. You’ve probably heard, in some hushed, serious tone, that you should “never, ever use += for string concatenation in Python.” And you’ve probably nodded along, thinking it’s one of those sacred rules, like not using goto. But here’s the thing: like most absolute rules in programming, it’s a simplification. A useful one, but a simplification nonetheless. The reality is more interesting, and knowing why is what separates you from someone who just parrots dogma.
The core issue isn’t the += operator itself; it’s the fundamental immutability of Python strings. You can’t change a string once it’s created. So, when you write a += b, what you’re actually asking Python to do is:
- Allocate new memory big enough to hold the combined result of
aandb. - Copy the contents of the original
ainto this new memory block. - Copy the contents of
bonto the end. - Rebind the name
ato this new, shiny string object. - The old
a? It’s now garbage, waiting to be collected.
Do this in a tight loop for, say, 100,000 iterations, and you’re forcing the poor interpreter to perform this allocate-and-copy dance 100,000 times. The first iteration copies 1 character, the second copies 2, the third 3… you see the pattern. This is an O(n²) operation, and it’s why your code suddenly feels like it’s running on a potato from 1998.
The join() Salvation
The str.join() method is the elegant solution to this madness. Its brilliance is in its simplicity: it knows the full size of the job from the very beginning.
Think of it like this: if you’re building a model ship in a bottle, += is like trying to add each piece by pulling the entire ship out, gluing on the new part, and trying to fit it all back in each time. It’s a nightmare. join(), however, is like having all the pieces laid out on your desk first. You know exactly how big the final ship will be, so you just get one perfectly-sized bottle and assemble it directly inside. One allocation, one continuous operation.
Here’s the classic, terrible += approach:
# Don't do this. Seriously.
def slow_concatenation(list_of_words):
result = ""
for word in list_of_words:
result += word # This is the performance crime.
return result
word_list = ["hello"] * 100000
# This will be... not fast.
final_string = slow_concatenation(word_list)
And here’s the correct, civilized way:
# Do this. Be happy.
def fast_concatenation(list_of_words):
return "".join(list_of_words)
word_list = ["hello"] * 100000
# This will be practically instantaneous.
final_string = fast_concatenation(word_list)
The join() method takes your iterable (e.g., a list), calculates the total length needed by iterating through it once, allocates a single string of that exact size, and then systematically fills it in. It’s an O(n) operation. The difference isn’t just measurable; it’s dramatic.
But Wait, It’s Not Always Evil
Here’s where the absolutists are wrong. CPython (the standard Python interpreter you’re probably using) has a sneaky optimization called string interning and a specific peephole optimization for +=. In some cases, it can actually avoid the catastrophic performance hit.
If you’re concatenating string literals, the interpreter is smart enough to do the whole thing at compile time. But more importantly, for str += other_str operations, sometimes the interpreter can see that there are no other references to str and can actually perform an in-place resize. This isn’t guaranteed by the language spec—it’s an implementation detail—and it only works when the left-hand string is the only reference to that object.
This means:
# This might be okay-ish in a tight loop in CPython...
s = ""
for i in range(1000):
s += "a"
# ...but THIS will be tragically slow because the list reference means 's' isn't the sole owner.
s = ""
list_of_refs = [s] # Now 's' is referenced elsewhere!
for i in range(1000):
s += "a" # The optimization can't kick in.
Relying on this is like relying on your cat to do your taxes. It might work out once, but it’s a terrible strategy. The moment your code gets a bit more complex, the optimization fails, and you’re back to O(n²) misery. Trust the method, not the magic.
So What Should You Actually Do?
The rule of thumb is simple and effective:
- For runtime concatenation of an arbitrary number of strings (like building a string from a list or generator), always use
''.join(iterable). It’s predictable, fast, and clear. - For a small, fixed number of concatenations (like
first_name + " " + last_name), using+is perfectly fine and more readable. It’s a case where the readability outweighs the negligible performance cost. - If you’re building a string incrementally from many parts and a list feels clunky, consider using an
io.StringIOobject. It’s like a mutable file-like object in memory that you write to, and then get the final string with.getvalue(). It’s another O(n) approach and can be very clean for complex building.
import io
# This is also excellent for complex, incremental building.
buffer = io.StringIO()
for num in range(10):
buffer.write(f"The number is {num}\n")
final_output = buffer.getvalue()
The goal isn’t to blindly follow a rule. It’s to understand the why. Now you know the why. So go forth and concatenate wisely. Your programs will thank you.