The Rust Collections Guide
Designing APIs Around Collections
Last Updated: 2026-04-05
Why collection-aware API design matters
Collection choice affects more than internal implementation. It shapes what callers are allowed to pass, what ownership costs they pay, how reusable a function becomes, and whether the API stays flexible as requirements change.
A collection-heavy API can be awkward even if the underlying logic is correct. A function that demands Vec<T> when it only needs read access forces callers into unnecessary ownership and allocation choices. A function that returns a concrete collection too early may reduce flexibility for downstream code. Good API design around collections is largely about expressing the minimum real requirement.
A useful mental model is this: accept the least restrictive input that still captures what the function needs, and return the most informative output that matches what the function actually produces.
Accept capabilities, not habits
A common beginner mistake is to write APIs in terms of whatever collection the implementation happens to use internally. But callers care about capabilities, not your current storage choice. If a function only needs to read a contiguous sequence, it usually wants a slice, not a vector. If it only needs text input, it usually wants &str, not String.
fn sum(values: &[i32]) -> i32 {
values.iter().sum()
}
fn main() {
let array = [1, 2, 3];
let vector = vec![4, 5, 6];
println!("{}", sum(&array));
println!("{}", sum(&vector));
}This API is flexible because it describes the real requirement: borrowed read access to contiguous i32 values. The function does not care whether the caller owns an array, a vector, or a subslice.
When to accept slices instead of vectors
If a function only needs to inspect or process a sequence of items without taking ownership, &[T] is often the right input type. It is more general than &Vec<T> and avoids forcing callers into one particular owned container.
fn average(values: &[f64]) -> Option<f64> {
if values.is_empty() {
return None;
}
let total: f64 = values.iter().sum();
Some(total / values.len() as f64)
}
fn main() {
let data = [1.0, 2.0, 3.0, 4.0];
let more = vec![5.0, 6.0, 7.0];
println!("{:?}", average(&data));
println!("{:?}", average(&more));
}A function taking &Vec<f64> would be needlessly narrower. It would reject arrays and other borrowed contiguous views for no semantic reason.
When `&Vec<T>` is usually the wrong choice
A shared reference to a vector, &Vec<T>, is almost always more specific than necessary. In most read-only APIs, the function cares about the elements, not about the fact that the caller stores them in a vector. That is why &[T] is usually better.
fn first_two(values: &[i32]) -> Option<(i32, i32)> {
match values {
[a, b, ..] => Some((*a, *b)),
_ => None,
}
}
fn main() {
let v = vec![10, 20, 30];
println!("{:?}", first_two(&v));
}There are rare cases where &Vec<T> may be justified because the function specifically needs vector-only methods or semantics, but that is much less common than people first assume.
When to accept mutable slices
If a function needs to edit elements in place but does not need to change the overall ownership or length of the sequence, &mut [T] is often the right API.
fn clamp_nonnegative(values: &mut [i32]) {
for value in values {
if *value < 0 {
*value = 0;
}
}
}
fn main() {
let mut numbers = vec![3, -1, 7, -5];
clamp_nonnegative(&mut numbers);
println!("{:?}", numbers);
}This is better than taking &mut Vec<T> when the function only needs element mutation. It allows callers to pass arrays, vectors, and mutable subranges.
When to accept owned vectors
Sometimes a function really does need ownership of a vector. This is appropriate when the function will consume the input, rearrange it destructively, store it long-term, or return ownership in modified form.
fn sorted(mut values: Vec<i32>) -> Vec<i32> {
values.sort();
values
}
fn main() {
let data = vec![4, 1, 3, 2];
println!("{:?}", sorted(data));
}Taking ownership is meaningful here because the function chooses to reuse and mutate the caller-provided allocation rather than allocate a new result from borrowed input.
Borrowed input, owned output
One of the strongest default API shapes in Rust is borrowed input plus owned output. The caller keeps ownership of the input, and the function returns a newly produced collection.
fn doubled(values: &[i32]) -> Vec<i32> {
values.iter().map(|n| n * 2).collect()
}
fn main() {
let input = [1, 2, 3, 4];
let out = doubled(&input);
println!("{:?}", out);
}This is often ideal because it is flexible for callers and clear about allocation. The function borrows what it reads and owns what it creates.
The same pattern for text: `&str` versus `String`
The same principles apply to text. Most text-processing functions should accept &str, not String or &String, because they usually only need borrowed read access to text.
fn shout(text: &str) -> String {
format!("{}!", text.to_uppercase())
}
fn main() {
let literal = "hello";
let owned = String::from("world");
println!("{}", shout(literal));
println!("{}", shout(&owned));
}This keeps the API flexible and avoids forcing callers to allocate a String just to pass text into a function.
When to return a concrete collection
Returning a concrete collection such as Vec<T>, HashMap<K, V>, or String is appropriate when the function's result is naturally an owned materialized value. This is often the clearest choice when the function builds a new result and callers are expected to keep or reuse it.
use std::collections::HashMap;
fn word_counts(text: &str) -> HashMap<String, usize> {
let mut counts = HashMap::new();
for word in text.split_whitespace() {
*counts.entry(word.to_string()).or_insert(0) += 1;
}
counts
}
fn main() {
println!("{:?}", word_counts("pear apple pear"));
}Returning the concrete map here is natural because the function is specifically producing an owned frequency table.
When to return an iterator instead
Sometimes returning an iterator is better than returning an owned collection. This keeps the API flexible because callers can choose whether to collect, sum, count, filter further, or consume items lazily.
fn positive_values<'a>(input: &'a [i32]) -> impl Iterator<Item = i32> + 'a {
input.iter().copied().filter(|n| *n > 0)
}
fn main() {
let values = [-2, 5, 0, 7, -1, 9];
let collected: Vec<i32> = positive_values(&values).collect();
println!("collected = {:?}", collected);
let sum: i32 = positive_values(&values).sum();
println!("sum = {}", sum);
}This style is especially useful when the function is really describing a sequence transformation rather than promising a specific storage choice.
Returning iterators versus returning vectors
A helpful rule is this: return an iterator when laziness and flexibility are part of the value of the API, and return a concrete collection when materialization is part of the intended result.
If the caller will almost always need a Vec<T>, returning a vector may be clearer. If the caller may want to count, filter, collect into different structures, or chain more iterator operations, returning an iterator may be better.
The choice is not about elegance alone. It is about whether storage is part of the contract or still a caller decision.
When accepting iterators makes sense
Sometimes an API should not care about the concrete input collection at all. In those cases, accepting something iterable can be more flexible than accepting a slice or vector.
fn total<I>(items: I) -> i32
where
I: IntoIterator<Item = i32>,
{
items.into_iter().sum()
}
fn main() {
let a = vec![1, 2, 3];
let b = [4, 5, 6];
println!("{}", total(a));
println!("{}", total(b));
}This is useful when the function conceptually consumes a stream of values rather than depending on contiguous layout or random access.
Slices versus generic iterators
Choosing between a slice parameter and a generic iterator parameter depends on what operations the function truly needs.
A slice is a better fit when the function needs contiguous-data features such as indexing, windows, chunking, or repeated traversal. A generic iterator is a better fit when the function only needs a one-pass stream of items.
fn adjacent_sums(values: &[i32]) -> Vec<i32> {
values.windows(2).map(|pair| pair[0] + pair[1]).collect()
}
fn main() {
let values = [1, 2, 3, 4];
println!("{:?}", adjacent_sums(&values));
}This function wants a slice because it uses windows, which depends on contiguous access.
Ownership strategies in API design
A collection-oriented API usually falls into one of a few ownership shapes.
Borrowed input, borrowed output when the function returns views into existing data.
Borrowed input, owned output when the function computes a new result.
Owned input, owned output when the function consumes and transforms an existing allocation.
Owned input, no output when the function stores or transfers the collection elsewhere.
Making this ownership shape explicit helps keep APIs predictable.
Borrowed input, borrowed output
A function can sometimes return borrowed views into input data rather than allocating new results. This is especially common with slices and string slices.
fn first_word(input: &str) -> &str {
match input.find(' ') {
Some(i) => &input[..i],
None => input,
}
}
fn main() {
let text = String::from("blue sky today");
println!("{}", first_word(&text));
}This style is efficient because no new allocation occurs, but it also means the returned value cannot outlive the input it borrows from.
Owned input, owned output for reuse of allocation
Sometimes taking ownership lets a function reuse existing allocation and avoid building a new collection from scratch. This can be both ergonomic and efficient.
fn keep_even(mut values: Vec<i32>) -> Vec<i32> {
values.retain(|n| n % 2 == 0);
values
}
fn main() {
let data = vec![1, 2, 3, 4, 5, 6];
println!("{:?}", keep_even(data));
}The function consumes the vector, mutates it in place, and returns it. That is a strong API when the caller is finished with the original input anyway.
Avoiding unnecessary cloning in APIs
Poor collection API design often leads to avoidable cloning. This usually happens when a function asks for ownership even though it only needs borrowed access, or when it returns a materialized collection even though an iterator would have been enough.
fn contains_name(names: &[String], target: &str) -> bool {
names.iter().any(|name| name == target)
}
fn main() {
let names = vec!["alice".to_string(), "bob".to_string()];
println!("{}", contains_name(&names, "alice"));
}This API lets callers keep ownership of their strings while the function performs a simple borrowed check. No cloning is needed.
Keeping APIs flexible without over-generalizing
Flexibility is good, but there is also a point where an API becomes more complicated than its use case justifies. Not every function needs complex generic iterator bounds. Not every helper should return impl Iterator. Sometimes a simple &[T] -> Vec<U> signature is the clearest and best choice.
The goal is not maximal abstraction. The goal is matching the real needs of callers while keeping the API easy to understand.
Concrete semantics sometimes matter
Sometimes the concrete collection type is not just an implementation detail. A function returning BTreeMap<K, V> is telling callers that sorted key order matters. A function taking VecDeque<T> may be explicitly about queue semantics. A function returning HashSet<T> communicates that uniqueness, not order, is the main property of the result.
In those cases, choosing the concrete collection in the public API is not overcommitment. It is useful semantic information.
A practical checklist for collection APIs
Ask a few direct questions.
Does the function need ownership, or only borrowed access?
Does it need contiguous-data features like indexing or windows, or would any iterator do?
Is the result naturally materialized, or would a lazy iterator be more useful?
Is the concrete collection type part of the meaning of the API, or just an internal storage choice?
Will this signature force callers into avoidable allocation or cloning?
These questions usually lead to a better design than choosing types by habit.
Common good defaults
Accept &[T] for read-only sequence processing.
Accept &mut [T] for in-place mutation of contiguous data.
Accept &str for read-only text input.
Accept owned Vec<T> or String when consuming and reusing caller-owned storage is meaningful.
Return Vec<T> or another concrete collection when the function naturally produces owned data.
Return iterators when flexibility and laziness are central to the API.
A small sandbox project
A tiny Cargo project is enough to compare several API styles side by side.
[package]
name = "collection-api-design-guide"
version = "0.1.0"
edition = "2024"Create and run it like this.
cargo new collection-api-design-guide
cd collection-api-design-guide
cargo runA minimal src/main.rs could look like this.
fn doubled(values: &[i32]) -> Vec<i32> {
values.iter().map(|n| n * 2).collect()
}
fn keep_positive(mut values: Vec<i32>) -> Vec<i32> {
values.retain(|n| *n > 0);
values
}
fn positive_iter<'a>(values: &'a [i32]) -> impl Iterator<Item = i32> + 'a {
values.iter().copied().filter(|n| *n > 0)
}
fn first_word(input: &str) -> &str {
match input.find(' ') {
Some(i) => &input[..i],
None => input,
}
}
fn main() {
let values = vec![-2, 1, 3, -4, 5];
println!("doubled = {:?}", doubled(&values));
println!("keep_positive = {:?}", keep_positive(values.clone()));
println!("positive_iter collected = {:?}", positive_iter(&values).collect::<Vec<_>>());
let text = String::from("blue sky today");
println!("first_word = {}", first_word(&text));
}This one small program shows several strong API shapes together: borrowed slice input with owned vector output, owned vector input for in-place reuse, iterator-returning APIs for flexibility, and borrowed string input with borrowed output.
