How to concatenate strings

Use `format!` for one-off concatenation or `String::push_str` (or the `+` operator) when building strings in a loop.

The string concatenation trap

You're porting a script from Python. You have a list of user IDs and you want to build a comma-separated report. You write let report = "User: " + id;. The compiler screams. You try let report = String::from("User: ") + id;. It compiles, but now id is gone. You try to print id on the next line and get E0382. You move to a loop, using format! to append each line. The code works, but when you run it on a large dataset, it takes ten seconds instead of ten milliseconds.

This is the classic Rust string concatenation journey. You expected a simple operation. You got a lesson in ownership, memory allocation, and the difference between a value and a reference. Rust forces you to think about where the string lives and who pays for it. Once you understand the mechanics, the choices become obvious.

Strings are boxes, not values

In Python or JavaScript, strings are immutable values that the runtime manages. You can glue them together without worrying about memory. In Rust, a String is a heap-allocated buffer. It owns its data. A &str is a reference to a sequence of UTF-8 bytes. It points to data owned by someone else.

Think of a String as a physical notebook. You own the notebook. You can write in it, tear out pages, or hand it to someone else. A &str is a bookmark pointing to a specific page in a library book. You can read the page, but you can't write on it, and you can't take the book home.

Concatenation in Rust usually means one of two things. You are either creating a new notebook that contains the combined text, or you are expanding your current notebook to fit more text. The operation you choose determines who owns the result, how many allocations happen, and whether your original data survives.

The three tools

Rust provides three primary ways to combine strings. Each has a distinct cost model and ownership behavior.

format! for one-offs

The format! macro is the most common tool. It takes a format string and a list of values, computes the result, and returns a new String. It handles type conversion automatically. You can mix String, &str, integers, and structs without explicit casting.

let name = "Alice";
let age = 30;

// format! creates a new String on the heap.
// It calculates the total size, allocates memory, writes the data, and returns the String.
let greeting = format!("Hello, {}! You are {} years old.", name, age);

// name and age are untouched. They are borrowed, not moved.
println!("Original name: {}", name);

format! is idiomatic for combining a few values. It reads like a sentence. The compiler expands the macro into code that writes directly to a buffer. It is safe and flexible. The trade-off is allocation. Every call to format! allocates a new String. If you call it inside a tight loop, you are allocating and freeing memory on every iteration.

The + operator for consuming concatenation

The + operator works for strings, but it behaves differently than in other languages. It requires the left operand to be a String and the right operand to be a &str. The result is a String. The left String is consumed.

let mut s1 = String::from("Hello");
let s2 = " world";

// s1 is moved into the + operation.
// The result is a new String containing "Hello world".
// s1 is no longer valid after this line.
let combined = s1 + s2;

// This would fail with E0382: use of moved value.
// println!("{}", s1);

The asymmetry comes from trait implementation. String implements Add<&str>. The method signature looks like fn add(self, other: &str) -> String. The self is taken by value, which moves the String. The other is borrowed. This design allows the + operator to reuse the buffer of the left String if there is enough capacity, avoiding a fresh allocation. If the left String has room, Rust appends the right slice in place. If not, it allocates a larger buffer, copies the data, and appends.

If you try to use + with two &str literals, the compiler rejects it with E0308 (mismatched types). "a" + "b" fails because "a" is a &str, not a String. You must own the left side to mutate the buffer.

push_str for incremental building

When you are building a string over time, push_str is the efficient choice. It appends a &str to a mutable String. It modifies the string in place. It grows the buffer only when necessary.

let mut result = String::new();

// push_str appends to the existing buffer.
// It borrows result mutably and extends the content.
result.push_str("First line");
result.push_str("\n");
result.push_str("Second line");

// result now contains "First line\nSecond line".
// Only one allocation occurred (plus potential growth).

push_str is the workhorse for loops and parsers. It avoids the overhead of creating intermediate String values. The String struct tracks its capacity. When you push data, Rust checks if the current capacity is sufficient. If it is, the data is copied into the buffer and the length is updated. If not, Rust allocates a new, larger buffer, copies the old data, frees the old buffer, and updates the pointer. This reallocation strategy uses geometric growth, typically doubling the capacity. This ensures that appending N items has amortized O(1) cost per item, rather than O(N) total cost.

Walking through a loop

The difference between tools becomes critical in loops. Consider building a log of events.

let events = vec!["Login", "Purchase", "Logout"];

// BAD: format! in a loop allocates a new String every iteration.
// The old String is dropped and freed.
// This creates N allocations and N deallocations.
let mut bad_log = String::new();
for event in &events {
    bad_log = format!("{}\nEvent: {}", bad_log, event);
}

// GOOD: push_str reuses the buffer.
// Allocations happen only when capacity is exceeded.
// With geometric growth, this is O(log N) allocations total.
let mut good_log = String::new();
for event in &events {
    good_log.push_str("Event: ");
    good_log.push_str(event);
    good_log.push('\n');
}

The bad_log version creates a new String on every iteration. The format! macro allocates memory, writes the combined text, and returns the new String. The assignment bad_log = ... drops the old String, freeing its memory. The next iteration repeats the process. For a small list, this is invisible. For a list of ten thousand items, the allocator becomes the bottleneck. The good_log version keeps one String alive. push_str writes into the existing buffer. The buffer grows a few times as it expands. The total work is proportional to the output size, not the square of the output size.

Pre-allocating capacity eliminates even the growth allocations. If you know the approximate size, use String::with_capacity.

// Estimate the size to avoid reallocations entirely.
// This is a convention when the size is predictable.
let mut log = String::with_capacity(1024);

for event in &events {
    log.push_str("Event: ");
    log.push_str(event);
    log.push('\n');
}

Convention aside: The community prefers format! for readability in one-off cases. It is the default choice unless profiling shows string building is a bottleneck. Don't optimize prematurely. Use push_str when you are in a loop or building a large string. Use with_capacity when you can estimate the size and want to squeeze out the last bit of performance.

Pitfalls and compiler errors

String concatenation triggers specific compiler errors that reveal the ownership model.

If you try to concatenate two string literals with +, you get E0308.

let s = "Hello" + " world";
// error[E0308]: mismatched types
//   --> src/main.rs:2:13
//    |
// 2  |     let s = "Hello" + " world";
//    |             ^^^^^^^ expected `String`, found `&str`

The left side must be a String. Literals are &str. You must convert the left side or use format!.

If you use a String after +, you get E0382.

let s1 = String::from("Hello");
let s2 = s1 + " world";
println!("{}", s1);
// error[E0382]: use of moved value: `s1`

The + operator consumes s1. The data moved into the result. s1 is no longer valid. If you need s1 afterwards, clone it before the operation.

let s1 = String::from("Hello");
let s2 = s1.clone() + " world";
println!("{}", s1); // OK

If you forget mut when using push_str, you get E0596.

let s = String::from("Hello");
s.push_str(" world");
// error[E0596]: cannot borrow `s` as mutable, as it is not declared as mutable

push_str requires &mut self. The variable must be mutable. This is a signal that the string is being modified in place.

Don't fight the compiler here. These errors are features. They prevent use-after-free bugs and accidental data loss. Read the error. It tells you exactly what ownership rule is being enforced.

Collections and join

When you have a collection of strings, looping with push_str is verbose. The join method on iterators is the idiomatic solution. It takes a separator and combines all items. It is optimized. join calculates the total length of the result first, allocates a single buffer of the exact size, and writes all items into it. This avoids reallocation entirely.

let words = vec!["Rust", "is", "fast"];

// join allocates once and writes all items.
// It is both readable and performant.
let sentence = words.join(" ");
// Result: "Rust is fast"

// join works on any iterator of string-like types.
let numbers = vec![1, 2, 3];
let csv = numbers.iter().map(|n| n.to_string()).collect::<Vec<_>>().join(",");
// Result: "1,2,3"

Convention aside: Always prefer join over manual loops for combining collections. It is faster because it pre-calculates capacity. It is safer because it handles empty collections correctly. It is more readable. If you find yourself writing a loop to build a string from a Vec, reach for join.

Decision matrix

Choose the right tool based on your scenario.

Use format! when you are combining a few values into a single string and readability matters more than micro-optimization. It handles type conversion, interpolation, and formatting in one call. It is the standard choice for logging, error messages, and one-off construction.

Use + when you have a String on the left and a &str on the right, and you want to consume the left String to produce a new one. It can reuse the left buffer if capacity allows. It is concise for simple appends where you don't need the original left value.

Use push_str when you are building a string incrementally in a loop or function to avoid repeated allocations. It modifies the string in place. It is the correct choice for parsers, accumulators, and any scenario where you append data step by step.

Use join when you have a collection of strings and want to combine them with a separator. It allocates once and writes all items. It is faster and more readable than a manual loop.

Pre-allocate with String::with_capacity when you can estimate the final size and want to eliminate reallocation overhead. This is a convention for performance-critical paths where the size is predictable.

Trust the borrow checker. If + feels restrictive, it is protecting you from accidental moves. If push_str requires mut, it is signaling mutation. Use format! for clarity. Use push_str for speed. Use join for collections. The compiler will guide you to the right choice.

Where to go next