How to Join a Vector of Strings in Rust

Use the `join` method on a `&[String]` or `&[&str]` slice, passing the desired separator as an argument.

When you need to smash strings together

You have a list of file paths. You need to pass them to a shell command as a single space-separated argument. Or you're building a CSV row from a list of fields. You grab your vector of strings and stare at it. You need to concatenate them with a separator between each item. In Python, you'd call "".join(list). In JavaScript, you'd use array.join(","). In Rust, the method lives on the slice, not the string, and it works on almost anything that looks like a string.

The join method takes a separator and returns a new String. It borrows your collection, so your original data stays intact. It handles memory allocation efficiently by calculating the exact size needed before writing a single byte. This makes it the standard tool for combining collections of strings.

The slice method and AsRef magic

The join method is defined on slices, specifically &[T] where T implements AsRef<str>. This trait bound is the key to its flexibility. AsRef<str> means the type can be viewed as a string slice without copying. String implements it. &str implements it. Cow<str> implements it. Even OsString has a similar join method for OS paths.

Because Vec<T> derefs to [T], you can call join directly on a vector. The compiler handles the coercion. You don't need to slice the vector manually, though doing so doesn't hurt.

fn main() {
    // Owned strings in a Vec
    let words: Vec<String> = vec!["Rust".to_string(), "is".to_string(), "fast".to_string()];
    
    // join takes a separator and returns a new String
    // It borrows the slice via deref coercion, so words is still usable
    let sentence = words.join(" ");
    
    println!("{}", sentence); // Rust is fast
    
    // The original vector remains available
    println!("Original: {:?}", words);
}

The separator is always a &str. It cannot be a String. This keeps the API simple and avoids unnecessary allocations for the glue between your data.

How join avoids reallocation

Many string operations in Rust suffer from reallocation. If you build a string by pushing characters or substrings in a loop, the underlying buffer often needs to grow. Each growth copies the existing data to a larger block of memory. This adds up.

join avoids this by performing two passes. The first pass measures. It iterates over the slice, summing the byte length of every element and the separator. It knows the exact capacity required. The second pass writes. It allocates the buffer once with the calculated capacity and copies every element and separator into place. No resizing. No reallocations.

This two-pass strategy is a convention in the standard library for collection operations. The cost of the extra iteration is negligible compared to the cost of memory reallocation and copying. You get predictable performance regardless of how many strings you join.

fn build_path(components: &[&str]) -> String {
    // join calculates capacity in a first pass
    // It allocates exactly once and copies in the second pass
    // This avoids the reallocation overhead of a manual loop
    components.join("/")
}

fn main() {
    let parts = ["usr", "local", "bin"];
    let path = build_path(&parts);
    println!("{}", path); // usr/local/bin
}

Convention aside: The community calls this "zero-allocation join" in the sense that there are zero reallocations. It still allocates the result string. The term highlights that the operation scales linearly without hidden memory costs.

Real-world: Building a query string

Query strings require key-value pairs joined by ampersands, with keys and values separated by equals signs. This is a realistic scenario where join shines. You map your data to strings and join the result.

fn build_query(params: &[(&str, &str)]) -> String {
    // Map each tuple to a "key=value" string
    // Collect into a Vec<String> to use join
    // join handles the "&" separator between pairs
    params
        .iter()
        .map(|(k, v)| format!("{}={}", k, v))
        .collect::<Vec<String>>()
        .join("&")
}

fn main() {
    let params = [("user", "alice"), ("role", "admin")];
    let query = build_query(&params);
    println!("{}", query); // user=alice&role=admin
}

The collect::<Vec<String>>() step is necessary because join requires a slice. The iterator produced by map is not a slice. You must materialize the collection before joining. If you want to skip the intermediate vector, you can use the itertools crate, which provides a join method that consumes an iterator directly.

Pitfalls and compiler errors

The most common error is trying to join a collection of non-strings. If you have a Vec<i32>, the compiler rejects you with E0277 (trait bound not satisfied). i32 does not implement AsRef<str>. You must convert the numbers to strings first.

fn main() {
    let numbers = vec![1, 2, 3];
    
    // This fails with E0277: the trait bound `{integer}: AsRef<str>` is not satisfied
    // let joined = numbers.join(","); 
    
    // Map to strings first, then join
    let joined = numbers
        .iter()
        .map(|n| n.to_string())
        .collect::<Vec<String>>()
        .join(",");
        
    println!("{}", joined); // 1,2,3
}

Another pitfall is passing a String as the separator. join expects &str. If you have a variable separator stored as a String, the compiler rejects you with E0308 (mismatched types). Use .as_str() or borrow the string.

fn main() {
    let words = vec!["a".to_string(), "b".to_string()];
    let sep = String::from("-");
    
    // This fails with E0308: expected `&str`, found `String`
    // let result = words.join(sep);
    
    // Borrow the String as a &str
    let result = words.join(&sep);
    println!("{}", result); // a-b
}

Empty slices return an empty string. A slice with one element returns that element without any separator. This behavior is consistent and safe. You don't need to check the length before calling join.

Don't fight the type system here. Map your data to strings before joining. Treat the separator as a slice, not an owned value.

Decision matrix

Use join when you have a collection of strings or string-like types and a fixed separator. It handles capacity allocation automatically and avoids reallocations. It is the idiomatic choice for combining lists.

Use String::with_capacity and push_str when you need conditional separators or complex logic between elements. join only inserts the separator between items. It cannot add a prefix, a suffix, or skip separators based on content. A manual loop gives you full control over the output structure.

Use format! when you are constructing a string from a few known variables rather than a dynamic collection. format!("{}:{}", key, value) is clearer and faster than vec![key, value].join(":"). format! avoids the overhead of creating a vector and calling a method.

Use itertools::join when you are working with an iterator and don't want to collect into a Vec first. The standard library join requires a slice, so you must allocate a vector. itertools provides a version that consumes the iterator directly, saving the intermediate allocation.

Prefer join over manual loops. Manual loops are a maintenance trap. They often guess capacity incorrectly, leading to reallocations. join is tested, optimized, and readable.

Where to go next