Jump to content

Rust String Concatenation: A Friendly, No-Nonsense Guide (with Optimal Patterns)

From JOHNWICK

Rust gives you two main string flavors: &str (a borrowed string slice) and String (an owned, heap-allocated, growable string). Concatenation is really just about where the bytes live and who owns them. Below is an engaging, practical tour through the most common combinations — plus a simple decision chart so you can pick the optimal approach for your use case.


Quick Primer: &str vs String

  • &str — a view into some UTF-8 bytes; not growable; no allocation on its own.
  • String — an owned, mutable buffer on the heap; growable; can allocate and re-allocate.

Rule of thumb: Whenever you combine pieces into a new result, someone must own the resulting bytes — i.e., you’ll end up with a String.


The Combinations You Asked About

1) &str + &str

You can’t use + directly (there’s no Add for &str). Create a new String via format!, concat, or join:

fn main() {
    let a: &str = "hello ";
    let b: &str = "world";
    // Readable and flexible
    let s1 = format!("{a}{b}");
    // Works with slices/arrays of &str
    let s2 = [a, b].concat();
    let s3 = [a, b].join("");
    println!("{s1} | {s2} | {s3}");
}

Bonus: Adjacent string literals concatenate at compile time with no allocation:
let s = "hello " "world"; // "hello world"


2) String + &str

This is the classic, efficient “grow the left string” pattern:

fn main() {
    let mut owned: String = "hello ".to_owned();
    let borrowed: &str = "world";
    owned.push_str(borrowed); // in-place append; may reuse allocation
    println!("{owned}");
}

You can also use +, but note it consumes the left side:

fn main() {
    let left: String = "hello ".to_owned();
    let right: &str = "world";
    let combined = left + right; // left is moved and no longer accessible
    println!("{combined}");
}

3) String + String

Right-hand side must be borrowed as &str (the Add impl is String + &str):

fn main() {
    let mut a: String = "hello ".to_owned();
    let b: String = "world".to_owned();
    // In-place, keeps `b` untouched
    a.push_str(&b);
    println!("{a} / still have b: {b}");
    // Or consume left with `+` (borrow right)
    let a2: String = "hello ".into();
    let b2: String = "world".into();
    let combined = a2 + &b2; // a2 moved, b2 remains
    println!("{combined} / still have b2: {b2}");
}

When You Want a New String (leave inputs untouched)

format! is the simplest, most readable choice:

fn main() {
    let a: &str = "hello ";
    let b: &str = "world";
    let together = format!("{a}{b}");
    println!("{together}");
}

It also works with String (the compiler borrows them as &str inside format!):

fn main() {
    let a: String = "hello ".into();
    let b: String = "world".into();
    let together = format!("{a}{b}");
    println!("{together}");
}

You can clone to keep originals and use +, but it’s usually noisier and can be wasteful:

fn main() {
    let a: String = "hello ".into();
    let b: &str = "world";
    let together = a.clone() + b; // a is cloned (extra allocation)
    println!("{together}");
}

Beyond the Basics: Many Pieces, Loops, and Performance If you’re building a string incrementally (especially inside a loop), prefer a single String buffer and push into it:

fn main() {
    let parts = ["user:", "alice", " id:", "42"];
    let mut out = String::new();
    // Optional but great for perf: estimate and reserve once
    out.reserve(parts.iter().map(|s| s.len()).sum());
    for p in parts {
        out.push_str(p);
    }
    println!("{out}");
}

If you already have a collection of &str, join is concise and efficient:

fn main() {
    let parts = vec!["foo", "bar", "baz"];
    let s = parts.join(","); // "foo,bar,baz"
    println!("{s}");
}

When you’re pushing single characters, use push:

out.push(' ');
out.push('🚀');
If you need to avoid allocations unless necessary, return a Cow<'a, str>:
use std::borrow::Cow;
fn maybe_title_case<'a>(s: &'a str, do_it: bool) -> Cow<'a, str> {
    if do_it {
        Cow::Owned(s.to_uppercase()) // allocates only in this branch
    } else {
        Cow::Borrowed(s)             // zero allocation
    }
}

If you only need to print and not keep a combined string, you can avoid allocation entirely: println!("{}{}", a, b); // formats directly to stdout

Optimal Choices (Cheat Sheet)

  • Two to a few parts (clarity first):
Use format!("{a}{b}{c}"). It’s clean, handles any mix of &str/String, and leaves inputs untouched.
  • Build incrementally in a loop:
Use a single String with with_capacity/reserve and push_str/push. This minimizes reallocations and copies.
  • Combine a list of &str:
Use parts.join("") (or parts.join(",") etc.). For a small fixed slice, [a, b, c].concat() works too.
  • Prefer not to lose the left value:
Don’t use + unless you want to move the left String. Instead, push_str on a mutable String, or use format!.
  • Don’t clone just to concatenate:
If you catch yourself writing a.clone() + ..., consider format! or push_str instead.


Common Pitfalls

  • String + String without &:
let s = a + b; fails because Add expects &str on the right. Use a + &b or a.push_str(&b).
  • Repeated s = s + piece in a loop:
This can reallocate every time. Use push_str on a pre-reserved String.
  • Forgetting UTF-8:
String is UTF-8. Concatenation is byte copy; you don’t need to worry about breaking characters unless you slice on byte boundaries improperly. Using the APIs above is safe.


Copy-Paste Examples You Can Run

Append borrowed to owned (reuses allocation)

fn main() {
    let mut s: String = "hello ".to_owned();
    let t: &str = "world";
    s.push_str(t);
    println!("{s}");
}

Keep both inputs, get a new String

fn main() {
    let a: String = "hello ".into();
    let b: &str = "world";
    let c = format!("{a}{b}");
    println!("{c}");
}

Consume left, borrow right (+)

fn main() {
    let a: String = "hello ".into();
    let b: String = "world".into();
    let c = a + &b;
    println!("{c} | still have b: {b}");
}

Many parts efficiently (reserve + push)

fn main() {
    let parts = ["hello", " ", "world", "!"];
    let total: usize = parts.iter().map(|s| s.len()).sum();
    let mut out = String::with_capacity(total);
    for p in parts {
        out.push_str(p);
    }
    println!("{out}");
} 

Summary Decision Chart

  • Just print? → println!("{}{}", a, b); (no allocation)
  • Return a new string from a few parts? → format!()
  • Grow one string repeatedly? → String::with_capacity + push_str/push
  • Have a list of &str? → .join("") or .concat()
  • Want +? → Remember: moves left String, borrows right &str (a + &b)

Read the full article here: https://medium.com/@trivajay259/rust-string-concatenation-a-friendly-no-nonsense-guide-with-optimal-patterns-ad157b770c81