String building
StringBuilder is the mutable counterpart of String for building and editing text: append, insert, delete, reverse, capacity growth and why repeated concatenation in a loop is quadratic.
Learning goals
- Use StringBuilder for loops that build text
- Know the cost of insert, delete and reverse
- Explain capacity growth
- Quantify the cost of repeated concatenation
Prerequisites
StringBuilder
StringBuilder holds a mutable character buffer (a dynamic array of chars) and edits it in place. Create one, call append repeatedly, and finish with toString(). It is not thread-safe; StringBuffer is the synchronised, slower variant that you rarely need.
Appending characters to a builder grows its buffer like a dynamic array, which the next animation shows.
1StringBuilder sb = new StringBuilder();2sb.append("id=").append(42).append(';').append(true); // chaining works because append returns the builder3String s = sb.toString(); // "id=42;true"4int len = sb.length(); // 10
StringBuilder capacity growth (2n + 2)
Interactive · edit the inputcharacters in the builder · length 0
value[] · capacity 4
new StringBuilder(4) allocates a buffer of 4 chars. Appending never copies until the buffer is full.
1// StringBuilder.append(char), simplified2void append(char c) {3if (count + 1 > value.length) { // no room for one more char4value = Arrays.copyOf(value, 2 * value.length + 2); // JDK 17 growth rule5}6value[count++] = c;7}
Live state
count
0
capacity
4
Execution trace (dry run table)Show 32 stepsHide
| Step | Line | State | What happens |
|---|---|---|---|
| 1 | L2 | count=0 capacity=4 | new StringBuilder(4) allocates a buffer of 4 chars. Appending never copies until the buffer is full. |
| 2 | L3 | count=0 capacity=4 c='b' | Room left: write 'b' directly. |
| 3 | L6 | count=1 capacity=4 | value[0] = 'b'; count = 1. |
| 4 | L3 | count=1 capacity=4 c='u' | Room left: write 'u' directly. |
| 5 | L6 | count=2 capacity=4 | value[1] = 'u'; count = 2. |
| 6 | L3 | count=2 capacity=4 c='i' | Room left: write 'i' directly. |
| 7 | L6 | count=3 capacity=4 | value[2] = 'i'; count = 3. |
| 8 | L3 | count=3 capacity=4 c='l' | Room left: write 'l' directly. |
| 9 | L6 | count=4 capacity=4 | value[3] = 'l'; count = 4. |
| 10 | L3 | count=4 capacity=4 c='d' | 5 > 4: the buffer is full. |
| 11 | L4 | count=4 capacity=10 | New capacity 2 x 4 + 2 = 10; 4 chars copied (4 copied in total). |
| 12 | L6 | count=5 capacity=10 | value[4] = 'd'; count = 5. |
| 13 | L3 | count=5 capacity=10 c='e' | Room left: write 'e' directly. |
| 14 | L6 | count=6 capacity=10 | value[5] = 'e'; count = 6. |
| 15 | L3 | count=6 capacity=10 c='r' | Room left: write 'r' directly. |
| 16 | L6 | count=7 capacity=10 | value[6] = 'r'; count = 7. |
| 17 | L3 | count=7 capacity=10 c='p' | Room left: write 'p' directly. |
| 18 | L6 | count=8 capacity=10 | value[7] = 'p'; count = 8. |
| 19 | L3 | count=8 capacity=10 c='a' | Room left: write 'a' directly. |
| 20 | L6 | count=9 capacity=10 | value[8] = 'a'; count = 9. |
| 21 | L3 | count=9 capacity=10 c='t' | Room left: write 't' directly. |
| 22 | L6 | count=10 capacity=10 | value[9] = 't'; count = 10. |
| 23 | L3 | count=10 capacity=10 c='t' | 11 > 10: the buffer is full. |
| 24 | L4 | count=10 capacity=22 | New capacity 2 x 10 + 2 = 22; 10 chars copied (14 copied in total). |
| 25 | L6 | count=11 capacity=22 | value[10] = 't'; count = 11. |
| 26 | L3 | count=11 capacity=22 c='e' | Room left: write 'e' directly. |
| 27 | L6 | count=12 capacity=22 | value[11] = 'e'; count = 12. |
| 28 | L3 | count=12 capacity=22 c='r' | Room left: write 'r' directly. |
| 29 | L6 | count=13 capacity=22 | value[12] = 'r'; count = 13. |
| 30 | L3 | count=13 capacity=22 c='n' | Room left: write 'n' directly. |
| 31 | L6 | count=14 capacity=22 | value[13] = 'n'; count = 14. |
| 32 | L7 | length=14 capacity=22 resizes=2 copies=14 return=length 14, capacity 22 | 14 chars appended with 2 resizes and 14 chars copied. Capacity roughly doubles each time, so appends are amortised O(1). Pass an expected size to the constructor to avoid resizes. |
Append
append accepts any type and writes at the end: amortised O(1) per character because the buffer grows geometrically. It is the right operation for building output in a loop.
Append the characters one by one and watch the capacity.
1StringBuilder sb = new StringBuilder();2for (int i = 0; i < 5; i++) sb.append(i).append(' ');3String line = sb.toString().trim(); // "0 1 2 3 4"4sb.setLength(0); // reuse the builder: length 0, capacity unchanged
StringBuilder capacity growth (2n + 2)
Interactive · edit the inputcharacters in the builder · length 0
value[] · capacity 4
new StringBuilder(4) allocates a buffer of 4 chars. Appending never copies until the buffer is full.
1// StringBuilder.append(char), simplified2void append(char c) {3if (count + 1 > value.length) { // no room for one more char4value = Arrays.copyOf(value, 2 * value.length + 2); // JDK 17 growth rule5}6value[count++] = c;7}
Live state
count
0
capacity
4
Execution trace (dry run table)Show 32 stepsHide
| Step | Line | State | What happens |
|---|---|---|---|
| 1 | L2 | count=0 capacity=4 | new StringBuilder(4) allocates a buffer of 4 chars. Appending never copies until the buffer is full. |
| 2 | L3 | count=0 capacity=4 c='b' | Room left: write 'b' directly. |
| 3 | L6 | count=1 capacity=4 | value[0] = 'b'; count = 1. |
| 4 | L3 | count=1 capacity=4 c='u' | Room left: write 'u' directly. |
| 5 | L6 | count=2 capacity=4 | value[1] = 'u'; count = 2. |
| 6 | L3 | count=2 capacity=4 c='i' | Room left: write 'i' directly. |
| 7 | L6 | count=3 capacity=4 | value[2] = 'i'; count = 3. |
| 8 | L3 | count=3 capacity=4 c='l' | Room left: write 'l' directly. |
| 9 | L6 | count=4 capacity=4 | value[3] = 'l'; count = 4. |
| 10 | L3 | count=4 capacity=4 c='d' | 5 > 4: the buffer is full. |
| 11 | L4 | count=4 capacity=10 | New capacity 2 x 4 + 2 = 10; 4 chars copied (4 copied in total). |
| 12 | L6 | count=5 capacity=10 | value[4] = 'd'; count = 5. |
| 13 | L3 | count=5 capacity=10 c='e' | Room left: write 'e' directly. |
| 14 | L6 | count=6 capacity=10 | value[5] = 'e'; count = 6. |
| 15 | L3 | count=6 capacity=10 c='r' | Room left: write 'r' directly. |
| 16 | L6 | count=7 capacity=10 | value[6] = 'r'; count = 7. |
| 17 | L3 | count=7 capacity=10 c='p' | Room left: write 'p' directly. |
| 18 | L6 | count=8 capacity=10 | value[7] = 'p'; count = 8. |
| 19 | L3 | count=8 capacity=10 c='a' | Room left: write 'a' directly. |
| 20 | L6 | count=9 capacity=10 | value[8] = 'a'; count = 9. |
| 21 | L3 | count=9 capacity=10 c='t' | Room left: write 't' directly. |
| 22 | L6 | count=10 capacity=10 | value[9] = 't'; count = 10. |
| 23 | L3 | count=10 capacity=10 c='t' | 11 > 10: the buffer is full. |
| 24 | L4 | count=10 capacity=22 | New capacity 2 x 10 + 2 = 22; 10 chars copied (14 copied in total). |
| 25 | L6 | count=11 capacity=22 | value[10] = 't'; count = 11. |
| 26 | L3 | count=11 capacity=22 c='e' | Room left: write 'e' directly. |
| 27 | L6 | count=12 capacity=22 | value[11] = 'e'; count = 12. |
| 28 | L3 | count=12 capacity=22 c='r' | Room left: write 'r' directly. |
| 29 | L6 | count=13 capacity=22 | value[12] = 'r'; count = 13. |
| 30 | L3 | count=13 capacity=22 c='n' | Room left: write 'n' directly. |
| 31 | L6 | count=14 capacity=22 | value[13] = 'n'; count = 14. |
| 32 | L7 | length=14 capacity=22 resizes=2 copies=14 return=length 14, capacity 22 | 14 chars appended with 2 resizes and 14 chars copied. Capacity roughly doubles each time, so appends are amortised O(1). Pass an expected size to the constructor to avoid resizes. |
Insert
insert(index, x) puts text in the middle: the tail must shift right by the length of the inserted text, so it costs O(n - index + inserted). Repeated insert(0, ...) builds a string in O(n²); append and reverse() at the end, or use a Deque, instead.
Choose insert in the editor animation and compare moving a long tail with inserting at the end.
1StringBuilder sb = new StringBuilder("abcdef");2sb.insert(2, "XY"); // "abXYcdef": the tail 'cdef' moved right by 23sb.insert(sb.length(), "!"); // same as append: nothing moves
StringBuilder insert, delete and reverse
Interactive · edit the inputStringBuilder contents · length 6
Insert "XY" at 2: the 4 chars from index 2 on must move right by 2.
1sb.insert(index, str); // shifts the tail right by str.length(): O(n - index + str)2sb.delete(start, end); // shifts the tail left; end is exclusive: O(n - start)3sb.reverse(); // two pointers swap from both ends: O(n)4sb.deleteCharAt(sb.length() - 1); // removing the last char shifts nothing: O(1)
Live state
index
2
str.length()
2
tail
4
Execution trace (dry run table)Show 2 stepsHide
| Step | Line | State | What happens |
|---|---|---|---|
| 1 | L1 | index=2 str.length()=2 tail=4 | Insert "XY" at 2: the 4 chars from index 2 on must move right by 2. |
| 2 | L1 | return="abXYcdef" chars moved=4 | Result "abXYcdef". Moved 4 chars: cost grows with the tail, so repeated insert(0, ...) is O(n²). |
Delete
delete(start, end) removes the range [start, end) and shifts the tail left; deleteCharAt(i) removes one character; setLength(n) truncates. Deleting near the end is cheap and deleting near the front moves almost everything: O(n - start).
Remove a range in the animation and check the moved-characters counter.
1StringBuilder sb = new StringBuilder("abcdef");2sb.delete(1, 4); // "aef": removed "bcd"3sb.deleteCharAt(sb.length() - 1); // "ae": O(1), nothing to shift4sb.setLength(1); // "a"
StringBuilder insert, delete and reverse
Interactive · edit the inputStringBuilder contents · length 6
Insert "XY" at 2: the 4 chars from index 2 on must move right by 2.
1sb.insert(index, str); // shifts the tail right by str.length(): O(n - index + str)2sb.delete(start, end); // shifts the tail left; end is exclusive: O(n - start)3sb.reverse(); // two pointers swap from both ends: O(n)4sb.deleteCharAt(sb.length() - 1); // removing the last char shifts nothing: O(1)
Live state
index
2
str.length()
2
tail
4
Execution trace (dry run table)Show 2 stepsHide
| Step | Line | State | What happens |
|---|---|---|---|
| 1 | L1 | index=2 str.length()=2 tail=4 | Insert "XY" at 2: the 4 chars from index 2 on must move right by 2. |
| 2 | L1 | return="abXYcdef" chars moved=4 | Result "abXYcdef". Moved 4 chars: cost grows with the tail, so repeated insert(0, ...) is O(n²). |
Reverse
reverse() reverses the characters in place with two swapping pointers: O(n), O(1) extra space. It treats a surrogate pair as one unit, so emoji are not broken. new StringBuilder(s).reverse().toString() is the idiomatic way to reverse a string.
Reverse is also how the digits of a base conversion get into the right order.
1String r = new StringBuilder("stressed").reverse().toString(); // "desserts"2boolean pal = new StringBuilder("level").reverse().toString().equals("level"); // true
StringBuilder insert, delete and reverse
Interactive · edit the inputStringBuilder contents · length 6
Insert "XY" at 2: the 4 chars from index 2 on must move right by 2.
1sb.insert(index, str); // shifts the tail right by str.length(): O(n - index + str)2sb.delete(start, end); // shifts the tail left; end is exclusive: O(n - start)3sb.reverse(); // two pointers swap from both ends: O(n)4sb.deleteCharAt(sb.length() - 1); // removing the last char shifts nothing: O(1)
Live state
index
2
str.length()
2
tail
4
Execution trace (dry run table)Show 2 stepsHide
| Step | Line | State | What happens |
|---|---|---|---|
| 1 | L1 | index=2 str.length()=2 tail=4 | Insert "XY" at 2: the 4 chars from index 2 on must move right by 2. |
| 2 | L1 | return="abXYcdef" chars moved=4 | Result "abXYcdef". Moved 4 chars: cost grows with the tail, so repeated insert(0, ...) is O(n²). |
Capacity
A builder's capacity is the size of its internal buffer; the default is 16, and new StringBuilder(n) reserves n. When an append would exceed the capacity, the buffer grows to 2 × capacity + 2 (JDK 17) and the existing characters are copied. capacity() reports it and ensureCapacity reserves in advance. Presize when you know the final length.
Pick an initial capacity of 4 and append 14 characters in the animation.
1StringBuilder sb = new StringBuilder(4);2for (int i = 0; i < 14; i++) sb.append('x');3int cap = sb.capacity(); // 22: 4 -> 10 -> 224int len = sb.length(); // 14
StringBuilder capacity growth (2n + 2)
Interactive · edit the inputcharacters in the builder · length 0
value[] · capacity 4
new StringBuilder(4) allocates a buffer of 4 chars. Appending never copies until the buffer is full.
1// StringBuilder.append(char), simplified2void append(char c) {3if (count + 1 > value.length) { // no room for one more char4value = Arrays.copyOf(value, 2 * value.length + 2); // JDK 17 growth rule5}6value[count++] = c;7}
Live state
count
0
capacity
4
Execution trace (dry run table)Show 32 stepsHide
| Step | Line | State | What happens |
|---|---|---|---|
| 1 | L2 | count=0 capacity=4 | new StringBuilder(4) allocates a buffer of 4 chars. Appending never copies until the buffer is full. |
| 2 | L3 | count=0 capacity=4 c='b' | Room left: write 'b' directly. |
| 3 | L6 | count=1 capacity=4 | value[0] = 'b'; count = 1. |
| 4 | L3 | count=1 capacity=4 c='u' | Room left: write 'u' directly. |
| 5 | L6 | count=2 capacity=4 | value[1] = 'u'; count = 2. |
| 6 | L3 | count=2 capacity=4 c='i' | Room left: write 'i' directly. |
| 7 | L6 | count=3 capacity=4 | value[2] = 'i'; count = 3. |
| 8 | L3 | count=3 capacity=4 c='l' | Room left: write 'l' directly. |
| 9 | L6 | count=4 capacity=4 | value[3] = 'l'; count = 4. |
| 10 | L3 | count=4 capacity=4 c='d' | 5 > 4: the buffer is full. |
| 11 | L4 | count=4 capacity=10 | New capacity 2 x 4 + 2 = 10; 4 chars copied (4 copied in total). |
| 12 | L6 | count=5 capacity=10 | value[4] = 'd'; count = 5. |
| 13 | L3 | count=5 capacity=10 c='e' | Room left: write 'e' directly. |
| 14 | L6 | count=6 capacity=10 | value[5] = 'e'; count = 6. |
| 15 | L3 | count=6 capacity=10 c='r' | Room left: write 'r' directly. |
| 16 | L6 | count=7 capacity=10 | value[6] = 'r'; count = 7. |
| 17 | L3 | count=7 capacity=10 c='p' | Room left: write 'p' directly. |
| 18 | L6 | count=8 capacity=10 | value[7] = 'p'; count = 8. |
| 19 | L3 | count=8 capacity=10 c='a' | Room left: write 'a' directly. |
| 20 | L6 | count=9 capacity=10 | value[8] = 'a'; count = 9. |
| 21 | L3 | count=9 capacity=10 c='t' | Room left: write 't' directly. |
| 22 | L6 | count=10 capacity=10 | value[9] = 't'; count = 10. |
| 23 | L3 | count=10 capacity=10 c='t' | 11 > 10: the buffer is full. |
| 24 | L4 | count=10 capacity=22 | New capacity 2 x 10 + 2 = 22; 10 chars copied (14 copied in total). |
| 25 | L6 | count=11 capacity=22 | value[10] = 't'; count = 11. |
| 26 | L3 | count=11 capacity=22 c='e' | Room left: write 'e' directly. |
| 27 | L6 | count=12 capacity=22 | value[11] = 'e'; count = 12. |
| 28 | L3 | count=12 capacity=22 c='r' | Room left: write 'r' directly. |
| 29 | L6 | count=13 capacity=22 | value[12] = 'r'; count = 13. |
| 30 | L3 | count=13 capacity=22 c='n' | Room left: write 'n' directly. |
| 31 | L6 | count=14 capacity=22 | value[13] = 'n'; count = 14. |
| 32 | L7 | length=14 capacity=22 resizes=2 copies=14 return=length 14, capacity 22 | 14 chars appended with 2 resizes and 14 chars copied. Capacity roughly doubles each time, so appends are amortised O(1). Pass an expected size to the constructor to avoid resizes. |
Repeated concatenation costs
Because strings are immutable, s += c in a loop builds a new string each time and copies all previous characters. Appending n characters this way writes 1 + 2 + ... + n = n(n + 1)/2 characters: O(n²). With a StringBuilder it is O(n). For 10⁵ characters that is about 5 × 10⁹ copied characters versus 10⁵.
The animation counts the characters written by each approach.
1String s = "";2for (int i = 0; i < 100_000; i++) s += 'x'; // O(n^2): minutes of copying on large n3StringBuilder sb = new StringBuilder();4for (int i = 0; i < 100_000; i++) sb.append('x'); // O(n)5String t = sb.toString();
s += c versus StringBuilder.append
Interactive · edit the inputcharacters appended so far · length 0
Strings are immutable: s += c builds a brand new string of length k + 1, copying all k old characters first.
1String s = "";2for (char c : text.toCharArray()) s += c; // each += allocates a new string and copies the old one3StringBuilder sb = new StringBuilder();4for (char c : text.toCharArray()) sb.append(c); // writes one char; occasional resize amortised away
Live state
Execution trace (dry run table)Show 13 stepsHide
| Step | Line | State | What happens |
|---|---|---|---|
| 1 | L1 | Strings are immutable: s += c builds a brand new string of length k + 1, copying all k old characters first. | |
| 2 | L2 | k=1 s += c=1 append=1 | Append #1: s += c wrote 1 chars (1 in total); sb.append wrote 1 (1 in total). |
| 3 | L2 | k=2 s += c=3 append=2 | Append #2: s += c wrote 2 chars (3 in total); sb.append wrote 1 (2 in total). |
| 4 | L2 | k=3 s += c=6 append=3 | Append #3: s += c wrote 3 chars (6 in total); sb.append wrote 1 (3 in total). |
| 5 | L2 | k=4 s += c=10 append=4 | Append #4: s += c wrote 4 chars (10 in total); sb.append wrote 1 (4 in total). |
| 6 | L2 | k=5 s += c=15 append=5 | Append #5: s += c wrote 5 chars (15 in total); sb.append wrote 1 (5 in total). |
| 7 | L2 | k=6 s += c=21 append=6 | Append #6: s += c wrote 6 chars (21 in total); sb.append wrote 1 (6 in total). |
| 8 | L2 | k=7 s += c=28 append=7 | Append #7: s += c wrote 7 chars (28 in total); sb.append wrote 1 (7 in total). |
| 9 | L2 | k=8 s += c=36 append=8 | Append #8: s += c wrote 8 chars (36 in total); sb.append wrote 1 (8 in total). |
| 10 | L2 | k=9 s += c=45 append=9 | Append #9: s += c wrote 9 chars (45 in total); sb.append wrote 1 (9 in total). |
| 11 | L2 | k=10 s += c=55 append=10 | Append #10: s += c wrote 10 chars (55 in total); sb.append wrote 1 (10 in total). |
| 12 | L2 | k=11 s += c=66 append=11 | Append #11: s += c wrote 11 chars (66 in total); sb.append wrote 1 (11 in total). |
| 13 | L4 | s += c=66 append=11 | 11 appends: s += c wrote 66 chars (n(n + 1)/2, quadratic) against 11 for the builder (linear, ignoring the occasional resize). |
Practice ladder
Now try it yourself
Easy
- Build "1,2,3,4,5" with a StringBuilder in a loop
- Reverse "abcdef" with StringBuilder
Medium
- Why is insert(0, c) in a loop quadratic? Show a linear alternative
- Predict the capacity after appending 20 characters to new StringBuilder(5)
Advanced
- Measure s += c against StringBuilder.append for n = 10⁴, 10⁵ and explain the growth
Key takeaways
- Use StringBuilder to build text in loops; append is amortised O(1).
- insert and delete shift the tail, so near the front they are O(n).
- Capacity grows to 2n + 2; s += c in a loop is quadratic.