Card 07/ 09

ExampleDifficulty: Intermediate3 min

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

building_strings.pypy
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()}")
OutputRan with python:3.14.8-slim-trixie (Python 3.14.8)
pieces 1000, length 4000
with +: 2002000 characters copied
with join: 4000 characters copied
same text: true
BuildingStrings.javajava
import 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));
    }
}
OutputRan with eclipse-temurin:25.0.4.1_1-jdk-noble (Java 25.0.4.1)
pieces 1000, length 4000
with +: 2002000 characters copied
with StringBuilder: 4000 characters copied
same text: true
Java's builder is `StringBuilder`, which plays the part of the join.
building_strings.tsts
const 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}`);
OutputRan with node:24.22.0-trixie-slim (Node 24.22.0), TypeScript 5.9.3
pieces 1000, length 4000
with +: 2002000 characters copied
with join: 4000 characters copied
same text: true

The 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.