Card 07/ 09
String Building Example: Joining a Thousand Words
Strings cannot change, so adding to one in a loop copies the whole string on every pass.
Strings are immutable in Python, Java and TypeScript, meaning a string cannot be changed in place, so adding a piece to one builds a new string and copies the old one into it. This card covers what that costs when a loop builds one long string, and the fix that keeps it linear: collect the pieces, then join them once.
Each listing builds the same 4,000-character text from 1,000 words of four characters, first with + and then by collecting the pieces and joining them. It counts the characters each way copies. With +, that is the length of every new string, because building it copies the whole string built until then and the new word. With a join, it is each piece once.
Before the output: how many characters does the + loop copy in total, against 4,000 for the join?
2,002,000, which is 4 + 8 + … + 4,000, or 4 × 500,500.
The highlighted lines are the two ways of building the text.
Building one string from 1,000 words, with + and with one join
words: list[str] = [f"w{i:03d}" for i in range(1_000)]
text = ""
copied_by_plus = 0
for word in words:
text = text + word
copied_by_plus += len(text)
parts: list[str] = []
for word in words:
parts.append(word)
joined = "".join(parts)
copied_by_join = len(joined)
print(f"pieces {len(words)}, length {len(joined)}")
print(f"with +: {copied_by_plus} characters copied")
print(f"with join: {copied_by_join} characters copied")
print(f"same text: {str(text == joined).lower()}")pieces 1000, length 4000
with +: 2002000 characters copied
with join: 4000 characters copied
same text: trueimport java.util.ArrayList;
import java.util.List;
public class BuildingStrings {
public static void main(String[] args) {
List<String> words = new ArrayList<>();
for (int i = 0; i < 1_000; i++) {
words.add(String.format("w%03d", i));
}
String text = "";
long copiedByPlus = 0;
for (String word : words) {
text = text + word;
copiedByPlus += text.length();
}
StringBuilder builder = new StringBuilder();
for (String word : words) {
builder.append(word);
}
String built = builder.toString();
long copiedByBuilder = built.length();
System.out.println("pieces " + words.size() + ", length " + built.length());
System.out.println("with +: " + copiedByPlus + " characters copied");
System.out.println("with StringBuilder: " + copiedByBuilder + " characters copied");
System.out.println("same text: " + text.equals(built));
}
}pieces 1000, length 4000
with +: 2002000 characters copied
with StringBuilder: 4000 characters copied
same text: trueconst words: string[] = Array.from({ length: 1_000 }, (_, i) => `w${String(i).padStart(3, "0")}`);
let text = "";
let copiedByPlus = 0;
for (const word of words) {
text = text + word;
copiedByPlus += text.length;
}
const parts: string[] = [];
for (const word of words) {
parts.push(word);
}
const joined = parts.join("");
const copiedByJoin = joined.length;
console.log(`pieces ${words.length}, length ${joined.length}`);
console.log(`with +: ${copiedByPlus} characters copied`);
console.log(`with join: ${copiedByJoin} characters copied`);
console.log(`same text: ${text === joined}`);pieces 1000, length 4000
with +: 2002000 characters copied
with join: 4000 characters copied
same text: trueThe join copies the text once, and + copies it about 500 times over. The gap grows with the number of pieces: the total for + is 4 × (1 + 2 + … + n), which is O(n²) in the number of pieces, while the join is O(n).
The counts are the copies each language's rules describe, and an implementation may skip some of them. CPython, the standard Python interpreter, can sometimes extend a string in place when nothing else refers to it, and V8, the engine Node runs on, can postpone the copy by linking the two strings until the result is read. Neither is promised, which is why Python's documentation warns that building a sequence by repeated concatenation has a quadratic cost. Java's + in this loop builds a new String every time.
Both fixes are one move: put the pieces somewhere that grows cheaply, then make the final string in one pass. A list or an array grows like a dynamic array, and so does a StringBuilder's internal buffer, which roughly doubles when full, so the builder's own copying stays linear by the same argument.
← → move through sections and cards