How to Concatenate Strings in Rust

Concatenate strings in Rust using the format! macro or push_str method to join text efficiently.

When you just want to join strings

You write Python. You type name + " " + last. It works. You switch to Rust. You type the same thing. The compiler throws a wall of red text at you. It feels like Rust is trying to stop you from doing the simplest thing in the world.

Rust isn't fighting you. Rust is making you pay attention to memory. In Python, strings are immutable objects managed by a garbage collector. You can glue them together without thinking about who owns the result. In Rust, a String is a heap-allocated buffer. If you combine two strings, you have to create a new buffer and copy the bytes. Someone has to pay for that allocation. Rust forces you to decide who pays and when.

The clipboard and the notebook

Think of &str as a bookmark. It points to text that lives somewhere else. It's cheap to copy. You can hand a bookmark to anyone, and it doesn't change the book.

Think of String as a physical notebook. You own the pages. If you want to combine two notebooks, you can't just tape them together and call it one notebook. You need a new notebook. You have to copy the pages from the first notebook, then the second, into the new one. The old notebooks might still exist, or they might be thrown away. You have to be explicit.

Rust gives you three main tools for this job. format! is the Swiss Army knife. push_str is the pen for adding to an existing notebook. The + operator exists, but it has quirks that make it a trap for beginners.

format! is your default tool

The format! macro is the standard way to build strings in Rust. It takes any number of arguments, formats them, and returns a new String. It handles the allocation, the copying, and the type conversion automatically.

fn main() {
    let name = "Alice";
    let age = 30;
    let active = true;

    // format! creates a new String on the heap.
    // It borrows the inputs, so name, age, and active stay valid.
    let message = format!("User {name} is {age} years old and active: {active}");

    println!("{message}");
}

format! works with String, &str, integers, booleans, and anything else that implements the Display trait. You don't need to convert numbers to strings manually. The macro handles the formatting logic for you.

Convention aside: The Rust community treats format! as the standard way to build strings. You'll see it everywhere. If you reach for +, other developers will assume you're optimizing prematurely or porting C++ code. format! is readable, safe, and fast enough for 99% of use cases.

Reach for format!. It handles the allocation, the borrowing, and the formatting in one shot.

Why format! works with numbers too

format! relies on traits. Specifically, it uses the Display trait to convert values to text. Every primitive type implements Display. When you write format!("{}", 42), the macro calls the Display implementation for i32 to get the string representation "42".

This design means you can mix types freely. You can pass a String, a &str, an i32, and a custom struct all in the same call. The macro expands to code that calculates the total length needed, allocates a buffer of that size, and writes each value into the buffer sequentially.

If you try to pass a type that doesn't implement Display, the compiler rejects it with E0277 (trait bound not satisfied). This usually happens with raw pointers or internal types that don't have a user-friendly text representation. Implement Display for your types if you want them to work with format!.

The + operator is a trap

Rust allows you to use + to concatenate strings, but the implementation is asymmetric. The + operator is syntactic sugar for the add method. For strings, the trait implementation looks like this:

impl Add<&str> for String {
    type Output = String;

    fn add(self, rhs: &str) -> String {
        // ...
    }
}

Notice the types. self is a String by value. rhs is a &str by reference. This means + takes ownership of the left string and borrows the right string. It appends the right slice to the left buffer and returns the modified left buffer.

This asymmetry breaks intuition. If you have two String variables, you can't just add them.

fn main() {
    let s1 = String::from("Hello");
    let s2 = String::from(" World");

    // This fails. s2 is a String, but + expects &str on the right.
    // let s3 = s1 + s2; // Error: mismatched types

    // You must borrow s2 explicitly.
    let s3 = s1 + &s2;
    println!("{s3}");
}

If you forget the borrow, the compiler rejects you with E0308 (mismatched types). It expects &str but finds String. You have to write &s2 to convert the String to a &str.

The + operator also moves s1. After s1 + &s2, s1 is no longer valid. You can't use it again. This is efficient because it reuses the buffer of s1, but it forces you to think about ownership. If you need s1 later, + is the wrong tool.

The + operator moves the left operand and borrows the right. That asymmetry breaks your intuition. Skip it.

Growing strings with push_str

When you already have a String and want to append text to it, use push_str. This method modifies the string in place. It avoids creating a new allocation if there is enough capacity.

fn main() {
    // Reserve capacity upfront to avoid reallocation.
    // The string grows without copying if we stay under 50 bytes.
    let mut buffer = String::with_capacity(50);

    buffer.push_str("Hello");
    buffer.push(' ');
    buffer.push_str("World");

    println!("{buffer}");
}

push_str takes a &str. If you have a String, you must borrow it: buffer.push_str(&other_string). push takes a single char. Use push when you have a character, not a string slice.

If you forget mut, the compiler rejects you with E0596 (cannot borrow as mutable). push_str modifies the string, so the variable must be declared mutable.

Convention aside: String::with_capacity is the standard pattern when you know the approximate size of the result. String grows geometrically when it runs out of space, which amortizes the cost of reallocation. However, reserving capacity eliminates the reallocations entirely. If you are building a large string, measure the expected size and reserve it.

Reserve capacity before a loop. Reallocation is the silent killer of string performance.

Building strings in a loop

A common mistake is using format! inside a loop to build a string.

fn bad_approach(chunks: &[&str]) -> String {
    let mut result = String::new();
    for chunk in chunks {
        // This creates a new String every iteration.
        // It copies the entire result plus the new chunk.
        // Complexity is O(N^2).
        result = format!("{result}{chunk}");
    }
    result
}

This approach allocates a new string on every iteration. It copies the entire accumulated text plus the new chunk. If you have 1000 chunks, you copy the text 1000 times. The complexity is quadratic. The performance drops dramatically as the string grows.

Use push_str in a loop instead. It appends to the existing buffer. If the buffer has capacity, it just copies the new bytes. If not, it reallocates and copies, but geometric growth keeps the amortized cost low.

fn good_approach(chunks: &[&str]) -> String {
    // Estimate total size to minimize reallocations.
    let total_len: usize = chunks.iter().map(|s| s.len()).sum();
    let mut result = String::with_capacity(total_len);

    for chunk in chunks {
        result.push_str(chunk);
    }
    result
}

For complex building logic, consider write!. The write! macro writes formatted output into a writer. String implements the Write trait. write! returns a result, which lets you handle errors if needed, but for String, it always succeeds. It's slightly more verbose but integrates well with builder patterns.

Don't concatenate in a loop with format!. You'll pay for the copy every time.

Joining a collection

When you have a collection of strings and want to join them with a separator, use the join method. It's available on slices and iterators of string-like types.

fn main() {
    let words = vec!["Rust", "is", "safe", "and", "fast"];

    // join calculates the total length upfront.
    // It allocates once and writes all parts.
    let sentence = words.join(" ");
    println!("{sentence}");

    // Works with iterators too.
    let numbers: Vec<i32> = vec![1, 2, 3];
    let csv = numbers.iter().map(|n| n.to_string()).collect::<Vec<_>>().join(",");
    println!("{csv}");
}

join is efficient. It calculates the total length of the result by summing the lengths of all items plus the separators. It allocates a buffer of that exact size and writes the data in one pass. It avoids reallocation entirely.

Use join for collections. It calculates the total length upfront and allocates once.

Decision matrix

Use format! when you need to build a string from mixed types, including numbers and booleans. Use format! when you want readable code and don't need micro-optimization. Use format! when the inputs are borrowed and you need a new owned String.

Use push_str when you are appending to an existing String in a loop or step-by-step builder. Use push_str when you want to modify a string in place and reuse its buffer. Use push_str with with_capacity when you know the final size to avoid reallocation.

Use join when you have a slice or collection of strings and need to combine them with a separator. Use join for CSV generation, path construction, or list formatting.

Use + only when you are certain the left operand is a String you want to move, the right operand is a &str, and you are in a context where the asymmetry is acceptable. In practice, this is rare. Prefer format! or push_str for clarity.

Use write! when you are implementing a builder pattern or writing to a generic Write trait object. Use write! when you need to handle write errors, though String writes never fail.

Where to go next