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 stdoutOptimal 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
Stringwithwith_capacity/reserveandpush_str/push. This minimizes reallocations and copies. - Combine a list of
&str: Useparts.join("")(orparts.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 leftString. Instead,push_stron a mutableString, or useformat!. - Don't clone just to concatenate:
If you catch yourself writing
a.clone() + ..., considerformat!orpush_strinstead.
Common Pitfalls
String + Stringwithout&:let s = a + b;fails becauseAddexpects&stron the right. Usea + &bora.push_str(&b).- Repeated
s = s + piecein a loop: This can reallocate every time. Usepush_stron a pre-reservedString. - Forgetting UTF-8:
Stringis 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 leftString, borrows right&str(a + &b)