How to append to a String without extra allocations
Short answer: Keep one String and push_str into it. A buffer you keep grows by doubling
when it runs out of room, so appending stays cheap however long the text gets, and
String::with_capacity sizes it up front so it never reallocates at all. The thing to avoid is
format! in a loop: every call builds a brand-new String and copies everything so far into it.
From a fresh project — cargo new string-demo, then replace src/main.rs with each block below.
The project name doesn’t matter; nothing in the output depends on it.
The loop that pays per iteration
Section titled “The loop that pays per iteration”Building text a piece at a time with format! reads nicely and hides its cost:
fn main() { let mut text = String::new(); for _ in 0..5 { text = format!("{text}ab"); } println!("{text}");}abababababNothing in that output shows what it cost. Count the allocations and it does. This installs a global allocator that counts every request and forwards it to the system one:
use std::alloc::{GlobalAlloc, Layout, System};use std::sync::atomic::{AtomicUsize, Ordering};
static ALLOCATIONS: AtomicUsize = AtomicUsize::new(0);
struct Counter;
unsafe impl GlobalAlloc for Counter { unsafe fn alloc(&self, layout: Layout) -> *mut u8 { ALLOCATIONS.fetch_add(1, Ordering::Relaxed); unsafe { System.alloc(layout) } } unsafe fn dealloc(&self, ptr: *mut u8, layout: Layout) { unsafe { System.dealloc(ptr, layout) } }}
#[global_allocator]static GLOBAL: Counter = Counter;
fn main() { let piece = "ab";
let mut with_format = String::new(); let start = ALLOCATIONS.load(Ordering::Relaxed); for _ in 0..100 { with_format = format!("{with_format}{piece}"); } let format_allocs = ALLOCATIONS.load(Ordering::Relaxed) - start; println!("format!: {} bytes, {} allocations", with_format.len(), format_allocs);
let mut with_push = String::new(); let start = ALLOCATIONS.load(Ordering::Relaxed); for _ in 0..100 { with_push.push_str(piece); } let push_allocs = ALLOCATIONS.load(Ordering::Relaxed) - start; println!("push_str: {} bytes, {} allocations", with_push.len(), push_allocs);
let mut with_plus = String::new(); let start = ALLOCATIONS.load(Ordering::Relaxed); for _ in 0..100 { with_plus = with_plus + piece; } let plus_allocs = ALLOCATIONS.load(Ordering::Relaxed) - start; println!("+: {} bytes, {} allocations", with_plus.len(), plus_allocs);}format!: 200 bytes, 196 allocationspush_str: 200 bytes, 6 allocations+: 200 bytes, 6 allocationsAll three loops end with the same 200 bytes. format! spent 196 allocations getting there — close
to two per append, because each call starts a fresh String and grows it while it writes.
push_str spent six.
One buffer, many appends
Section titled “One buffer, many appends”The push_str loop is the whole fix:
fn main() { let mut text = String::new(); for _ in 0..5 { text.push_str("ab"); } println!("{text}");}abababababSame output as the format! version. The binding needs mut, and the method borrows the String,
so it stays yours for as many appends as you like — building a string from
parts has the full tour of push_str and push. What’s
new here is what the buffer does when it fills up.
How the buffer grows
Section titled “How the buffer grows”Print the capacity next to the length and the growth is on screen:
fn main() { let mut text = String::new(); for _ in 0..8 { text.push_str("hello"); println!("{} bytes, capacity {}", text.len(), text.capacity()); }}5 bytes, capacity 810 bytes, capacity 1615 bytes, capacity 1620 bytes, capacity 3225 bytes, capacity 3230 bytes, capacity 3235 bytes, capacity 6440 bytes, capacity 64When an append would run past the capacity, the buffer moves to a new allocation twice the size — 8, then 16, 32, 64. The reallocations get rarer as the text grows, which is why six of them covered all 200 bytes in the counter above.
Size it up front
Section titled “Size it up front”Know the size, or a good guess at it, and with_capacity skips the growth entirely:
fn main() { let mut text = String::with_capacity(30); for _ in 0..6 { text.push_str("hello"); println!("{} bytes, capacity {}", text.len(), text.capacity()); }}5 bytes, capacity 3010 bytes, capacity 3015 bytes, capacity 3020 bytes, capacity 3025 bytes, capacity 3030 bytes, capacity 30One allocation, and the capacity never moves. Guessed high and the String outlives the building?
shrink_to_fit() hands the slack back — a 5-byte String built with with_capacity(64) drops to
a capacity of 5.
What about + in a loop?
Section titled “What about + in a loop?”That third row is the one worth pausing on: with_plus + piece cost the same six allocations as
push_str. + on a String builds nothing new — it moves the left operand and pushes the right
one into it, growing the same buffer. So text = text + piece in a loop is not the allocation
disaster it’s rumoured to be. What it can’t do is take anything but &str on the right, or
pre-size the buffer; and a piece you computed, like a number, still pays for its own to_string
first.
When the piece is a value rather than a literal, write! appends it straight into the buffer:
use std::fmt::Write;
fn main() { let mut line = String::with_capacity(64); let name = "Ada"; let year = 1843; write!(line, "{name}, born {year}").unwrap(); println!("{line}");}Ada, born 1843write! is format! that appends instead of allocating — same format syntax, writing into a
String you already own. The unwrap() never fires; appending to a String can’t fail. The
format mini-language itself — named arguments, inline captures, widths — has its own article later
in this chapter.
Related
Section titled “Related”- How to build a string from parts —
+,push_strandformat!, and what each one consumes - What is the difference between String and &str? — the owned buffer underneath all of this
- Working with text — the rest of the chapter