Apps Artificial Intelligence Cloud Computing CSS Cybersecurity Data Science Database Go JavaScript Linux Python Rust Software Engineering Web Development

Choosing &str, String, and Cow for Rust Text APIs

4 min read .
Choosing &str, String, and Cow for Rust Text APIs

Rust has several common ways to represent UTF-8 text, and choosing between &str, String, and Cow<'a, str> is fundamentally an ownership decision.

The best API is usually the one that asks callers for the least ownership it needs and returns ownership only when the result requires it.

Use &str when you only need to read text

A string slice borrows UTF-8 text owned elsewhere:

fn is_blank(value: &str) -> bool {
    value.trim().is_empty()
}

This accepts borrowed views into a String, string literals, and other string slices without taking ownership or allocating.

For synchronous helper functions that inspect text during the call, &str is usually the most flexible parameter type.

Prefer &str over &String

A function taking &String unnecessarily requires the caller to have a String specifically.

A function taking &str can accept both:

let owned = String::from("hello");

is_blank(&owned);
is_blank("literal");

This follows a broader Rust API guideline: borrow the abstraction you need, not a more specific owning container.

Use String when ownership must outlive the call

A struct that stores dynamic text generally needs to own it:

struct Job {
    name: String,
}

If a constructor receives a borrowed &str and stores a String, it must allocate and copy:

impl Job {
    fn new(name: &str) -> Self {
        Self {
            name: name.to_owned(),
        }
    }
}

That is appropriate when the Job must remain valid independently of the caller’s buffer.

For constructors, accepting impl Into<String> can be convenient when both owned and borrowed callers are common:

impl Job {
    fn new(name: impl Into<String>) -> Self {
        Self { name: name.into() }
    }
}

This can avoid copying when the caller already has a String, while allowing a &str at the cost of allocation.

Do not use Into<String> automatically everywhere. Generic parameters can make signatures and diagnostics more complex. A plain String or &str is often clearer when caller behavior is predictable.

Use Cow<str> when results are usually unchanged

Cow, short for clone-on-write, can represent either borrowed or owned data.

It is useful for transformations where most inputs can be returned unchanged but some require allocation.

use std::borrow::Cow;

fn normalize_spaces(input: &str) -> Cow<'_, str> {
    if input.contains("  ") {
        Cow::Owned(input.split_whitespace().collect::<Vec<_>>().join(" "))
    } else {
        Cow::Borrowed(input)
    }
}

When no double spaces are present, the function returns a borrowed view without allocating. When normalization is needed, it returns an owned String.

The caller can treat both variants through the same string-like interface.

Do not use Cow without a measurable reason

Cow adds another ownership state for readers of the code to understand.

If nearly every input requires transformation, returning String is simpler. If nothing requires ownership, return &str. Cow is most compelling when the fast path can genuinely borrow and the allocating path is meaningful enough to avoid when possible.

Clarity is often more valuable than eliminating a small allocation in cold code.

Lifetimes reveal what a returned borrow depends on

A function can return a string slice borrowed from its input:

fn first_word(input: &str) -> &str {
    input.split_whitespace().next().unwrap_or("")
}

The output cannot outlive the input. Rust’s lifetime elision rules make that relationship concise in this example.

When a function might return borrowed or owned data through Cow, the lifetime carries the same idea: a borrowed variant is tied to the input, while an owned variant contains its own storage.

If the returned value must survive after all inputs are gone, return an owned String.

Mutation changes the ownership requirement

String owns a growable UTF-8 buffer and supports mutation:

fn add_suffix(mut value: String) -> String {
    value.push_str("-processed");
    value
}

Taking ownership can be efficient when the caller no longer needs the original String because its allocation may be reused.

If the caller should retain ownership, take &str and create a new String, or take &mut String when in-place mutation is part of the API contract.

Choose based on semantics first; optimize copying after measuring.

Remember that strings are UTF-8

Neither String nor &str supports arbitrary integer indexing by character position.

A byte offset is not necessarily a Unicode scalar-value boundary, and a visible grapheme can contain multiple scalar values.

If your API discusses “characters,” define what that means for the problem: bytes, Unicode scalar values via .chars(), or user-perceived grapheme clusters handled by an appropriate Unicode library.

Ownership type and text segmentation are separate concerns.

Common pitfalls

Returning String from every helper

This can introduce unnecessary allocations and hides opportunities to borrow.

Accepting &String

It is more restrictive than &str for read-only text.

Using Cow as a universal optimization

It complicates APIs when the borrowed path is rare or irrelevant.

Cloning to silence lifetime errors

A clone may be correct, but first identify the intended ownership relationship. The compiler error often points to an API boundary that should own data explicitly.

A practical decision rule

Use &str when the function only observes text during a borrow. Use String when a value must own, store, or freely mutate its buffer. Consider Cow<'a, str> when an operation can usually return borrowed input but occasionally needs to create transformed owned text.

These choices make allocation behavior and lifetime expectations visible in the type signature, which is one of Rust’s strongest tools for building predictable APIs.

Related Posts

chevron-up