String owns growable UTF-8 text. str is a string slice made of valid UTF-8 bytes, and you usually work with its borrowed form, &str.
Both len() and range indexing count bytes. Treating bytes as characters, or slicing through a UTF-8 code point, produces the wrong answer or a panic.
Prefer &str for read-only parameters and use String when you must own or grow text. Choose bytes, Unicode scalar values, or grapheme clusters deliberately.
What it is and why it exists
Rust’s core string types are String and str. A String owns a growable sequence of UTF-8 bytes on the heap. str is a dynamically sized string-slice type representing valid UTF-8 bytes, so a bare str usually appears behind a pointer such as &str, Box<str>, or Arc<str>.
The usual pair is an owned String and a borrowed &str. A String allocates, grows, and releases its buffer. A &str describes a contiguous piece of text without taking responsibility for releasing it. This borrowing lets a function read the caller’s text without copying it first.
A string literal also has type &str, but it normally borrows static data in the program binary. A &str taken from a String may instead point into a heap buffer. Storage location doesn’t define the string type; ownership and the UTF-8 invariant do.
You’ll meet these types in function parameters, parsers, log messages, protocol fields, and user input. The right choice depends on whether the API must retain the input, change its length, or accept data that isn’t guaranteed to be UTF-8. It doesn’t depend on whether the sample happens to look like plain ASCII.
| Form | Owns bytes | Can grow | Common use |
|---|---|---|---|
String | Yes | Yes | Returning new text, struct fields, incremental construction |
&str | No | No | Read-only parameters, literals, borrowed substrings |
&mut str | No | No | In-place edits that preserve the byte length |
Box<str> | Yes | No | Owned, fixed-length text that needs no spare capacity |
Both String and str guarantee valid UTF-8. File names, C strings, and arbitrary network payloads may not satisfy that condition; consider Path/OsStr, CStr, or a byte slice instead. A lossy conversion changes data and shouldn’t be used merely to make the types fit.
How it works
Ownership and borrowed views
A String manages a buffer pointer, byte length, and capacity. Assigning it to another variable uses move semantics by default: ownership of the buffer transfers and the old variable can no longer be used. Only an explicit clone() copies the bytes.
Borrowing a &str from a String doesn’t copy the text. The borrow carries a data address and byte length and may cover the full string or one valid subrange. While a shared borrow is still used, Rust rejects mutation that could reallocate the buffer, so a reference can’t silently dangle after push_str().
The diagram shows the conceptual relationship, not an external ABI you can depend on. String owns the heap buffer; &str is only a view over a valid UTF-8 region. Borrow lifetimes keep the view from outliving its data.
UTF-8 and three kinds of length
UTF-8 uses one to four bytes for one Unicode scalar value. str::len() returns bytes, chars() iterates Unicode scalar values , and what a user sees as one character may be a grapheme cluster made from several scalar values. These units aren’t interchangeable.
For example, é can be the single scalar value U+00E9 or a sequence containing e and the combining accent U+0301. The two strings may look alike while having different byte and scalar counts. Rust’s standard library neither normalizes Unicode automatically nor provides full grapheme cluster segmentation.
Range slicing uses byte offsets, and both ends must fall on UTF-8 character boundaries. &text[a..b] panics for an invalid boundary, while text.get(a..b) returns None. char_indices() supplies each scalar value with a valid byte offset, which makes it useful when a scan feeds a later slice operation.
Allocation and mutation
String::new() creates an empty string and normally allocates nothing before the first growth. String::with_capacity(n) reserves room for at least n bytes and suits builders that already know their approximate output size. Capacity is spare space for future growth, not string content; len() reports only the valid bytes currently stored.
push() appends one char, while push_str() appends a &str. insert(), remove(), truncate(), and replace_range() take byte positions, and the relevant positions must be character boundaries. Safe APIs preserve the UTF-8 invariant, but they can’t make every runtime position valid.
String + &str moves the left-hand String and appends the right-hand text. It fits a one-off concatenation that deliberately consumes the left side. If you still need every input, format!() or an explicitly built String is clearer. Reusing one buffer in a loop also tends to make ownership easier to see.
Examples
Borrow input and return an owned result
This function only reads the name during the call, so its parameter is &str. The result must survive the function call, so it returns a new String.
fn greeting(name: &str) -> String {
let mut message = String::with_capacity("Hello, ".len() + name.len());
message.push_str("Hello, ");
message.push_str(name);
message
}
fn main() {
let owned = String::from("Ferris");
let first = greeting(&owned);
let second = greeting("Rust");
println!("{first}");
println!("{second}");
println!("still owned: {owned}");
}Hello, Ferris
Hello, Rust
still owned: FerrisIn greeting(&owned), deref coercion lets Rust use &String as &str; the literal is already &str. Both calls only borrow their inputs, so owned remains available for the final print. The reservation counts bytes, exactly the unit needed for the two UTF-8 segments that will be appended.
If the function stored the name in a returned struct, accepting String or impl Into<String> might be a better contract. Parameter types should state ownership, not pursue generic signatures as a goal of their own.
Inspect UTF-8 boundaries
This program prints the byte count, scalar count, and starting byte offset for each scalar value. The two get() calls differ by one starting position, but one succeeds and the other fails.
fn main() {
let text = "Aé中👋";
println!("bytes: {}", text.len());
println!("scalars: {}", text.chars().count());
for (byte_offset, scalar) in text.char_indices() {
println!("{byte_offset}: {scalar}");
}
println!("1..3: {:?}", text.get(1..3));
println!("2..3: {:?}", text.get(2..3));
let decomposed = "e\u{301}";
println!(
"decomposed bytes/scalars: {}/{}",
decomposed.len(),
decomposed.chars().count()
);
}bytes: 10
scalars: 4
0: A
1: é
3: 中
6: 👋
1..3: Some("é")
2..3: None
decomposed bytes/scalars: 3/2é occupies the byte range 1..3, so that range produces a &str. Offset 2 lies inside its encoding, which makes get(2..3) return None. The decomposed e\u{301} contains two scalar values and three bytes, although an interface may display it as one grapheme cluster.
If the requirement says “at most 20 user-visible characters,” chars().take(20) still isn’t necessarily correct because it truncates by scalar value. You need to choose Unicode grapheme segmentation rules explicitly and decide whether normalization happens first.
Reserve capacity and edit at a valid boundary
The path builder adds the byte lengths of every part and separator, then reuses one buffer. The second half replaces text at the position returned by find(). A successful &str match always begins at a character boundary.
fn join_path(parts: &[&str]) -> String {
let separators = parts.len().saturating_sub(1);
let byte_len = parts.iter().map(|part| part.len()).sum::<usize>() + separators;
let mut path = String::with_capacity(byte_len);
for (index, part) in parts.iter().enumerate() {
if index > 0 {
path.push('/');
}
path.push_str(part);
}
path
}
fn main() {
let path = join_path(&["用户", "42", "settings"]);
println!("{path}");
println!("bytes: {}", path.len());
let mut status = String::from("状态: ready");
let start = status.find("ready").expect("marker is present");
status.replace_range(start.., "done");
println!("{status}");
}用户/42/settings
bytes: 18
状态: doneUsing len() in the capacity calculation is correct because allocators care about bytes, not display characters. saturating_sub(1) keeps the separator count at zero for an empty slice. When a builder can’t estimate its input size, starting with String::new() is also correct, although growth may require reallocations.
Here replace_range(start.., "done") changes the byte length, so it can operate on String but not &mut str. The String still contains valid UTF-8 after the replacement.
Return borrowed fields from input
The parser neither rewrites its fields nor needs them to outlive the source record, so it returns two &str values pointing into its input. Both split_once() and trim() can produce borrowed views without creating a String for either field.
fn parse_record(line: &str) -> Option<(&str, &str)> {
let (key, value) = line.split_once('=')?;
let key = key.trim();
let value = value.trim();
if key.is_empty() {
return None;
}
Some((key, value))
}
fn main() {
for line in ["color = blue", " retries=3 ", " = missing"] {
match parse_record(line) {
Some((key, value)) => println!("{key} -> {value}"),
None => println!("invalid: {line:?}"),
}
}
}color -> blue
retries -> 3
invalid: " = missing"Lifetime elision ties both references in the return type to the function’s only input reference. While the caller keeps either field, it can’t destroy line or mutate it in a conflicting way. If a field must enter a configuration object that outlives the input, call to_owned() at that ownership boundary.
This implementation rejects a missing separator or empty key but permits an empty value. A real parser should state such rules in its signature and tests. Option only distinguishes success from failure, so use Result when callers need the reason.
Pitfalls
Treating bytes as characters
Fix: Name the unit required by the domain. Protocol lengths, capacity, and storage sizes usually count bytes; code-point work can use chars(); cursor movement, deleting “one character,” and many length limits require grapheme segmentation. Don’t rename one count and pass it off as another.
Slicing with an arbitrary range
Fix: If the offset comes from untrusted input, use get() and handle None. If it comes from a text scan, retain the byte boundary from char_indices(), find(), or another matching API. To truncate by scalar or grapheme, iterate over that unit first and then convert the result to a byte range.
Forcing ownership on a read-only API
Fix: Use &str for an ordinary read-only parameter. Accept or create a String when the function must store the text, transfer it to another owner, or return an independent value. impl AsRef<str> adds generic surface area, so reserve it for APIs that genuinely need several wrapper types.
Growing a string while it is borrowed
Fix: Reorder the operations so the shared borrow ends before mutation, or save an owned result before changing the source. Don’t bypass the error with an unsafe pointer. The compiler is preventing a potentially dangling view, not enforcing a cosmetic syntax preference.
Mistaking lowercase conversion for text comparison
Fix: Define the comparison contract first. ASCII protocol fields can use eq_ignore_ascii_case(). Natural-language search, sorting, and deduplication need chosen normalization, case-folding, and locale rules. The standard library can’t choose those product semantics for you.
Inside String storage
The UTF-8 invariant
You can think of String as a byte vector carrying a valid-UTF-8 invariant. Safe constructors validate outside bytes, and safe mutation methods accept input that can’t break the encoding. As a result, String::as_str() can produce &str without validating the full buffer on every read.
String::from_utf8(Vec<u8>) returns an error for invalid bytes and retains the original vector in that error for recovery. from_utf8_lossy() replaces invalid sequences and returns Cow<str>; the word lossy describes its data contract, not a display option. An unchecked unsafe constructor is only reasonable after the caller has already proved the bytes valid.
Safe Rust relies on this invariant. Creating an invalid String through unsafe code and then calling methods that assume UTF-8 violates their preconditions. Before removing one validation pass, put an auditable validity proof at the same boundary.
Descriptors and layout boundaries
At the behavioral level, String records a data location, current byte length, and usable capacity, while the text bytes live in its owned buffer. Moving a String normally transfers the buffer’s ownership information without copying each byte. The language promises the ownership behavior, not a stable C ABI field order.
&str is a fat pointer to the dynamically sized str type and carries a data address and byte length. It has no capacity because a borrow can’t grow the referenced region. Taking a substring creates another address-and-length view that still borrows the original storage.
Don’t turn an address or size_of result observed during debugging into a promise across targets. Pointer width, layout optimization, and ABI belong to the target and implementation boundary. For FFI, use a representation and lifetime contract agreed by both sides instead of exposing String directly.
Capacity is not length
Capacity is the number of bytes the buffer can hold before it must reallocate. reserve(additional) guarantees room for at least additional bytes beyond the current length, but the actual capacity may be greater. with_capacity() also promises only a lower bound, so tests shouldn’t assert an exact growth factor.
Preallocation fits builders with a reliable upper bound or an output size that is cheap to add up. A large guess may retain unused memory, while frequent shrink_to_fit() calls can force later growth to allocate again. Without measurements, describe allocation behavior rather than claiming a fixed speedup.
try_reserve() reports an unsatisfied capacity request through a Result-style error instead of leaving allocation failure only to the default handling path. When processing external length fields, check addition and multiplication for overflow first. A capacity API can’t repair an earlier integer error.
Empty strings are ordinary input
An empty String may have both zero length and zero capacity, and an empty &str is still a valid slice. Many boundary bugs come from assuming at least one character and calling something like chars().next().unwrap(), not from the string types themselves.
An API should say whether empty text is a valid value, a missing value, or an error. If the domain distinguishes “no field” from “a present but empty field,” use Option<&str> or a domain type instead of making the empty string represent both states.
Unicode boundaries and costs
Bytes, scalar values, and grapheme clusters
Bytes are the unit of storage and I/O. Rust’s char represents a Unicode scalar value, not a UTF-16 code unit and not necessarily one screen character. Grapheme clusters come from Unicode text segmentation rules and more closely match the unit a user perceives when moving a cursor or pressing backspace.
Even a grapheme count doesn’t determine terminal columns or layout width. Combining marks, full-width characters, emoji sequences, fonts, and the rendering environment all matter. Text-field limits, database column limits, and terminal alignment should have separate definitions rather than sharing a vague character_count.
Normalization addresses the representation of canonically equivalent sequences, segmentation finds boundaries, and case folding supports caseless matching. They are separate operations, and their order can affect a result. If the domain needs any of them, put the rules, Unicode version, and test cases in the contract.
Indexing costs
len() reads a stored byte length and takes constant time. A full pass through bytes() or chars() is linear in the input byte length; chars().count() must decode the string and can’t be derived from len(). Finding scalar value number n also requires decoding from a known boundary.
Consequently, repeatedly calling text.chars().nth(i) inside a 0..text.chars().count() loop rescans from the start and may grow quadratically on long inputs. Iterate once with chars(), use char_indices() when you also need byte positions, or build an index in the domain’s chosen unit when frequent random access is required.
Byte search isn’t inherently wrong. It is often exactly right for ASCII separators, protocol prefixes, and buffer-size calculations. The algorithm’s unit must match the domain, and bytes may be reinterpreted as str only at valid boundaries.
String API boundaries
When to return a borrow
Returning &str says the result is part of the input or some other existing storage, so its lifetime must be tied to that source. strip_prefix(), split_once(), and trim() can all return borrowed views because they don’t create new bytes. These APIs allocate nothing for the result, but callers can’t keep it beyond the source’s lifetime.
Case conversion, replacement, and formatting usually create bytes, so they naturally return String. A reference to a temporary String created inside the function can’t be returned, and a lifetime annotation can’t extend the temporary’s life. When a function creates and delivers text, it should return an owned result.
A String is the simplest struct field when the struct owns its data independently. A borrowed &'a str field works for an intentional view type, but it ties the struct’s lifetime to outside storage. Cow<'a, str> can express “borrowed if unchanged, owned after modification,” at the cost of another state callers must understand.
Common conversions differ in both cost and failure behavior:
| Conversion | Result | Allocation or validation |
|---|---|---|
String::from(text) | String | Allocates and copies the UTF-8 bytes in text |
owned.as_str() | &str | Borrows without allocation |
String::from_utf8(bytes) | Result<String, FromUtf8Error> | Reuses the vector buffer and validates UTF-8 |
str::from_utf8(bytes) | Result<&str, Utf8Error> | Borrows the bytes and validates UTF-8 |
String::from_utf8_lossy(bytes) | Cow<str> | May borrow valid input; allocates replacement text for invalid input |
Convert at the boundary where data enters the system, and internal code can rely on the UTF-8 invariant of String and str. If the program needs both raw bytes and decoded text internally, name and retain both forms instead of making repeated lossy conversions.
Outside UTF-8
Operating-system paths aren’t guaranteed to convert to str. Keep path logic in Path/OsStr, and call to_str() or an explicitly lossy conversion only at a display or protocol boundary that requires Unicode. A file name that can’t be decoded isn’t the same as a missing file.
C strings end with a null byte and can’t contain one internally; their encoding also isn’t automatically UTF-8. At a C boundary, CString/CStr handles terminators and pointer representation, while to_str() performs UTF-8 validation. Raw-pointer validity and lifetime remain part of the FFI safety contract.
Use Vec<u8> or &[u8] for arbitrary binary payloads. Convert to String or &str only after validation succeeds at the boundary. That keeps decoding failure as an input error callers can handle instead of hiding it behind replacement characters or a later string failure.
Further reading
4 questions · 1 predict-the-output · 1 spot-the-bug