String Types - Basic Introduction

Understand Rust's two primary string types, String and &str, their memory layout, how to create and use them, and the role of byte strings and other text-handling types.

Text data in Rust is not handled by a single, all-purpose type. The language splits string handling into two closely related but distinct forms, and the difference between them is one of the first things every Rust beginner needs to understand.

The two you will use in almost every program are String and &str. One owns the text it contains; the other borrows it. This document covers what each one is, why the split exists, how they live in memory, and how to choose between them.

UTF-8 Everywhere:

All standard Rust string types store valid UTF-8. There is no separate ASCII or Latin-1 string type in the standard library. If you need to work with raw bytes that are not guaranteed to be valid UTF-8, you use byte slices (&[u8]), OsString, or other dedicated types — not String or &str.

String Literals

When you write "hello" in your source code, Rust creates a string literal. Its type is &'static str.

let greeting = "hello";
// greeting: &str, specifically &'static str

A string literal is embedded directly into the compiled binary. It lives for the entire duration of the program ('static lifetime) and is stored in a read-only section of memory. You cannot modify a string literal because you do not own it — you only have a reference to it.

The 'static lifetime is not something you normally need to write out. Rust infers it, but it is worth knowing because it explains why you can return a string literal from any function without worrying about ownership.

fn default_greeting() -> &'static str {
    "Hi there"
}

Behind the scenes, str (without the &) is an unsized type that represents a sequence of valid UTF-8 bytes. You can never hold a bare str in a variable; you always work with it through a reference — &str. Beginners can think of &str as a borrowed view into some UTF-8 text, whether that text comes from a literal, a String, or somewhere else.

Literal works everywhere:

If you see "some text" being passed into a function that expects &str and it compiles cleanly, everything is working as intended. The literal is automatically a valid borrowed string.

Byte Strings

Sometimes you need to represent a sequence of bytes that is not guaranteed to be valid UTF-8 — for example, raw binary data, network protocol bytes, or file magic numbers. Rust provides byte strings for this.

A byte string literal looks like a string literal prefixed with b, and its type is &'static [u8; N] — a reference to a fixed-size array of bytes.

let raw: &[u8; 4] = b"\x48\x65\x6c\x6c";  // "Hell" in ASCII
let magic: &[u8; 4] = b"\x89PNG";

Byte strings are not the same as regular strings. You cannot pass a byte string directly to a function that expects &str, and they do not carry any UTF-8 guarantees. They are useful when you need to interact with bytes at a low level but still want the convenience of a literal syntax.

Not a string:

Byte strings are not String or &str. Trying to use them in string contexts will produce a type error. If you need to convert a validated byte slice into a &str, use std::str::from_utf8(), which returns a Result — the bytes might not be valid UTF-8.

The String Type

String is an owned, growable, heap-allocated UTF-8 string. When you need to build up text at runtime, read a file into memory, or take ownership of some text, String is the type you reach for.

It is provided by the standard library, not by the language core, but it is so fundamental that it is available without any imports. A String can be created from a literal with String::from or by calling .to_string() on a &str.

let mut s = String::from("hello");
s.push_str(", world!");
println!("{}", s); // prints: hello, world!

A String is essentially three pieces of data on the stack: a pointer to the heap-allocated buffer, the length (number of bytes currently used), and the capacity (total allocated space). The actual UTF-8 bytes live on the heap. This is why String can grow — it can reallocate a larger buffer when needed without affecting the stack footprint.

Indexing is not what you expect:

You cannot index into a String with s[0] in Rust. The compiler will reject it. This is because UTF-8 characters can take between 1 and 4 bytes. Direct byte indexing would give you a byte, not a character, and might split a multi-byte character in half, which is memory-unsafe. If you need to access individual characters, you must iterate with .chars().

Using Strings

Creating and combining strings is something you will do constantly. The following code shows the most common patterns.

// Creation
let empty = String::new();
let from_literal = "initial content".to_string();
let also_from_literal = String::from("initial content");
// Building
let mut builder = String::new();
builder.push('H');
builder.push_str("ello");
// Concatenation with +
let hello = String::from("Hello, ");
let name = String::from("Rust");
let greeting = hello + &name;   // hello is moved, name is borrowed
// println!("{}", hello);  // error: hello has been moved
println!("{}", greeting);
// Concatenation with format! macro
let world = "World";
let excited = format!("{} {}!", "Hello", world);

The + operator takes ownership of the left-hand side and borrows the right-hand side as a &str. This design avoids unnecessary allocations: the left-hand String's buffer can be reused to append the right-hand content. If you need to keep all original strings, use the format! macro, which allocates a new String and leaves the inputs untouched.

format! works exactly like println! but returns the resulting string instead of printing it. It is often the most readable way to build a string from multiple parts.

When you need a &str but have a String

Functions that work with string data often accept &str so they can accept both string literals and String values without taking ownership. Passing a String to such a function works because Rust automatically dereferences the String into a &str (via the Deref trait).

fn print_message(msg: &str) {
    println!("Message: {}", msg);
}
let owned = String::from("Hello");
print_message(&owned);        // borrowing the String
print_message("Hello");       // literal works directly

This auto-deref behavior is one of the reasons &str is the idiomatic parameter type for read-only string data.

Slicing can panic at runtime:

Taking a slice of a string like &s[0..2] uses byte indices. If the slice boundary lands in the middle of a multi-byte UTF-8 character, the program will panic. Always prefer safe iteration or .get(..) which returns an Option, when dealing with user-provided or variable-length text.

Strings in Memory

Understanding how String and &str sit in memory makes the ownership rules feel less arbitrary.

A &str is a fat pointer: it contains the address of the first byte and the length. The data it points to could be in the static read-only memory (for literals), on the heap (for a slice of a String), or even on the stack (if you somehow had a byte array that is valid UTF-8). The important part is that &str never owns the data — it is always a borrow.

A String owns its heap allocation. The stack portion of a String is a pointer, a length, and a capacity. When a String is dropped, its heap buffer is deallocated automatically.

&str (borrows data):
  stack: [ pointer | length ]  --->  (UTF-8 bytes somewhere in memory)
String (owns data):
  stack: [ pointer | length | capacity ]  --->  heap: [ UTF-8 bytes ... ]

When you take a slice of a String like &my_string[..], you get a &str whose pointer points into the String's heap buffer. The original String still owns the memory, so the borrow checker ensures the slice does not outlive the String.

Always 8 bytes on 64-bit systems:

Regardless of how much text a &str refers to, the &str itself is always two pointer-sized values (address and length). On a 64-bit machine, that means 16 bytes on the stack. This is why passing &str around is cheap.

Other String-Like Types

String and &str cover the majority of text handling. However, the standard library provides a few other string-like types for specific use cases. You will encounter them as your programs grow.

  • OsString and &OsStr — For platform-native strings that may not be valid UTF-8 (command-line arguments, environment variables, file paths).
  • PathBuf and &Path — Specialized wrappers around OsString/OsStr that add path-specific operations.
  • CString and &CStr — Null-terminated strings for interoperability with C code (FFI).

These types are conversions that go beyond the scope of a basic introduction. The key takeaway is that they exist for situations where UTF-8 is not the right representation, and you should reach for them when standard String/&str is not sufficient.


Summary

The single most important distinction to internalize is this: String is for when you need to own and potentially modify text data; &str is for when you only need to look at it. This split is what lets Rust string handling feel both safe and efficient without a garbage collector.

Once you are comfortable creating String values from literals, passing &str into functions, and using format! to build text without accidentally moving values, you have the foundation for every string operation you will encounter in Rust. From here, you can explore deeper topics like iterating over grapheme clusters, working with OsString for file paths, and understanding how the Deref trait makes the two types feel seamless together.