Post

Strings and String-Like Types in Rust

A concise guide about Strings and String-Like Types in Rust.

Strings and String-Like Types in Rust

Rust has several string and string-like types.

This can initially feel confusing because languages such as C usually have one primary representation for strings:

1
char *str;

Rust separates strings based on important properties:

  • Does the value own its memory?
  • Is the text guaranteed to be valid UTF-8?
  • Is it intended for the operating system?
  • Is it intended for C APIs?
  • Is it actually a filesystem path rather than text?

The most important types are:

1
2
3
4
5
6
7
8
9
10
11
String       owned UTF-8 text
&str         borrowed UTF-8 text

OsString     owned OS-native string
&OsStr       borrowed OS-native string

CString      owned C-compatible string
&CStr        borrowed C-compatible string

PathBuf      owned filesystem path
&Path        borrowed filesystem path

Understanding these types is particularly important for systems programming because operating systems and C APIs do not necessarily use UTF-8 strings.

1. String

String is Rust’s owned, growable, UTF-8 encoded string.

1
2
3
4
5
6
let mut name = String::from("Amir");

name.push('!');
name.push_str(" Nikpour");

println!("{name}");

Conceptually:

1
2
3
4
String
 ├── pointer ────────> heap allocation
 ├── length
 └── capacity

A String owns the underlying memory.

Therefore, it can grow:

1
2
3
4
5
let mut text = String::new();

text.push_str("hello");
text.push(' ');
text.push_str("world");

UTF-8

String must always contain valid UTF-8.

1
let text = String::from("hello");

The bytes inside the string are guaranteed to represent valid UTF-8.

For example:

1
2
3
let text = String::from("é");

println!("{}", text.len());

The result is:

1
2

because é occupies two bytes in UTF-8.

Therefore:

1
text.len()

returns the number of bytes, not the number of Unicode characters.

Use:

1
text.chars().count()

if you want the number of Unicode scalar values.

2. &str

&str is a borrowed string slice.

It does not own the string data.

1
2
3
let text = String::from("hello");

let slice: &str = &text;

Conceptually:

1
2
3
4
5
String
   |
   | borrow
   v
 &str

A string literal is also a &str:

1
let text: &str = "hello";

The literal is stored in the program’s binary and normally has a 'static lifetime:

1
let text: &'static str = "hello";

3. String vs &str

This is one of the most important distinctions in Rust.

1
2
3
4
5
String
 └── owns the string

&str
 └── borrows string data

Example:

1
2
3
fn print_name(name: &str) {
    println!("{name}");
}

You can pass both:

1
2
3
4
let name = String::from("Amir");

print_name(&name);
print_name("Amir");

This is one reason &str is commonly used for function parameters when the function only needs to read the text.

If a function needs to store or own the text:

1
2
3
fn create_user(name: String) {
    // owns name
}

you may use String.

4. str Is Unsized

str is a dynamically sized type (DST).

You normally cannot have:

1
let text: str;

because the compiler does not know the size of str at compile time.

Instead, you use a pointer to it:

1
let text: &str;

A &str is a fat pointer containing information such as:

1
2
3
&str
 ├── pointer
 └── length

This allows Rust to know which bytes belong to the string slice.

5. OsString

OsString is an owned string intended for communication with the operating system.

It is important because operating-system strings are not necessarily valid UTF-8.

For example, Unix allows arbitrary byte sequences except NUL in many pathname contexts.

Windows has different native string representations.

Therefore, Rust provides:

1
std::ffi::OsString

Example:

1
2
3
use std::ffi::OsString;

let name = OsString::from("config.txt");

The important idea is:

1
2
3
4
5
String
 └── guaranteed UTF-8

OsString
 └── OS-compatible representation

You should use OsString when dealing with values coming from OS interfaces where UTF-8 is not guaranteed.

6. &OsStr

&OsStr is the borrowed version of OsString.

1
2
3
4
5
use std::ffi::{OsStr, OsString};

let name = OsString::from("config.txt");

let name_ref: &OsStr = &name;

Conceptually:

1
2
3
4
5
OsString
   |
   | borrow
   v
 &OsStr

This is similar to:

1
2
3
4
5
String
   |
   | borrow
   v
 &str

but the important difference is that OsStr does not promise UTF-8.

7. Why OsString Exists

Consider an environment variable:

1
2
3
4
5
use std::env;

for (key, value) in env::vars_os() {
    println!("{key:?} = {value:?}");
}

Notice the _os version:

1
env::vars_os()

instead of:

1
env::vars()

The normal version returns:

1
String

and therefore requires valid Unicode.

The OS version returns:

1
OsString

because the underlying operating-system data might not be valid Unicode.

Similarly:

1
std::env::args()

returns Unicode strings and can fail when arguments aren’t valid Unicode.

While:

1
std::env::args_os()

returns:

1
OsString

and preserves the underlying OS representation.

This distinction is very important in system programs.

8. Converting OsString to String

You cannot simply assume an OsString contains valid UTF-8.

You can explicitly request a lossy conversion:

1
2
3
4
5
let os_string = std::ffi::OsString::from("hello");

let string = os_string.to_string_lossy();

println!("{string}");

to_string_lossy() returns:

1
Cow<'_, str>

If the content is valid UTF-8, it can borrow it.

If it is not valid UTF-8, invalid sequences are replaced with the Unicode replacement character:

1
�

You can also consume the OsString:

1
2
3
4
5
6
7
8
let result = os_string.into_string();

match result {
    Ok(string) => println!("{string}"),
    Err(original) => {
        println!("Not valid UTF-8: {original:?}");
    }
}

This does not silently lose information.

9. CString

CString is an owned string designed for interoperability with C.

1
2
3
use std::ffi::CString;

let text = CString::new("hello").unwrap();

C strings are traditionally represented as:

1
char *

and terminated by a NUL byte:

1
'h' 'e' 'l' 'l' 'o' '\0'

Rust’s String does not have this guarantee.

Therefore, when calling a C API that expects a C string, use CString.

10. Why Can’t We Pass String Directly to C?

Suppose a C function expects:

1
void print_name(const char *name);

You cannot simply pass:

1
2
3
let name = String::from("Amir");

some_c_function(name);

because a Rust String is not a C string.

A Rust String is represented approximately as:

1
pointer + length + capacity

while a C string is:

1
2
3
4
pointer
   |
   v
'a' 'm' 'i' 'r' '\0'

The C API determines the string length by finding the NUL terminator.

Rust instead stores the length separately.

11. Creating a CString

Use:

1
2
3
use std::ffi::CString;

let name = CString::new("Amir").unwrap();

CString::new() can fail.

Why?

Because C strings cannot contain an embedded NUL byte.

This is invalid:

1
let name = CString::new("Amir\0Nikpour");

The result is:

1
Err(NulError)

because C would interpret the first NUL as the end of the string.

12. Passing CString to C

To get a raw pointer:

1
2
3
let name = CString::new("Amir").unwrap();

let ptr = name.as_ptr();

The pointer has type:

1
*const c_char

For example:

1
2
3
unsafe {
    libc::puts(name.as_ptr());
}

The CString must remain alive while C uses the pointer:

1
2
3
4
5
let name = CString::new("Amir").unwrap();

unsafe {
    libc::puts(name.as_ptr());
}

Do not do something like:

1
let ptr = CString::new("Amir").unwrap().as_ptr();

and then use ptr.

The temporary CString is immediately dropped, leaving a dangling pointer.

13. CStr

CStr is the borrowed counterpart of CString.

1
2
3
4
5
CString
   |
   | borrow
   v
 &CStr

Example:

1
2
3
4
5
use std::ffi::{CStr, CString};

let owned = CString::new("hello").unwrap();

let borrowed: &CStr = owned.as_c_str();

CStr represents an existing NUL-terminated C string without owning it.

14. CString vs CStr

The relationship is similar to:

1
2
3
4
String      -> &str
OsString    -> &OsStr
CString     -> &CStr
PathBuf     -> &Path

The owned types own their underlying data.

The borrowed types provide a view into existing data.

15. Converting CStr to Rust Text

You can attempt to interpret a C string as UTF-8:

1
2
3
4
5
6
7
let c_string = CString::new("hello").unwrap();

let c_str = c_string.as_c_str();

let text = c_str.to_str().unwrap();

println!("{text}");

to_str() returns:

1
Result<&str, Utf8Error>

because a C string is not necessarily valid UTF-8.

You can also use:

1
let text = c_str.to_string_lossy();

which returns:

1
Cow<'_, str>

16. PathBuf

PathBuf is an owned filesystem path.

1
2
3
use std::path::PathBuf;

let path = PathBuf::from("/home/amir/file.txt");

It may look like a string, but semantically it represents a path, not arbitrary text.

Therefore:

1
2
3
4
5
PathBuf
 └── owned filesystem path

&Path
 └── borrowed filesystem path

17. &Path

&Path is the borrowed form of PathBuf.

1
2
3
use std::path::Path;

let path = Path::new("/tmp/file.txt");

Or:

1
2
3
let path_buf = PathBuf::from("/tmp/file.txt");

let path: &Path = &path_buf;

18. Why Use Path Instead of String?

Consider:

1
2
3
fn read_file(path: String) {
    // ...
}

This says the function needs to own an arbitrary string.

But:

1
2
3
fn read_file(path: &Path) {
    // ...
}

communicates the actual meaning:

This parameter represents a filesystem path.

It also gives access to path-specific operations:

1
2
3
4
5
6
path.exists();
path.is_file();
path.is_dir();
path.parent();
path.file_name();
path.extension();

This is much better than manually manipulating strings.

19. PathBuf and Path

Like the other owned/borrowed pairs:

1
2
3
4
5
PathBuf
   |
   | borrow
   v
 &Path

Example:

1
2
3
4
5
let mut path = PathBuf::from("/tmp");

path.push("file.txt");

println!("{}", path.display());

Notice the .display().

Unlike String, PathBuf does not implement Display directly.

Instead:

1
path.display()

returns a display adapter.

This is why:

1
println!("{}", path);

doesn’t work, while:

1
println!("{}", path.display());

does.

20. Path Is Closely Related to OsStr

One of the most useful relationships to remember is:

1
2
PathBuf  ──> OsString
Path     ──> OsStr

A filesystem path is essentially an OS string with path semantics.

You can access its OS representation:

1
2
3
let path = Path::new("/tmp/file.txt");

let os_str = path.as_os_str();

And convert a path from an OS string:

1
2
3
let os_string = std::ffi::OsString::from("/tmp/file.txt");

let path = PathBuf::from(os_string);

21. The Main String Family

The relationships can be summarized as:

1
2
3
4
5
6
7
8
9
10
                 Owned              Borrowed
                 ─────              ────────

UTF-8 text       String             &str

OS string        OsString           &OsStr

C string         CString            &CStr

Filesystem path  PathBuf            &Path

This is a very useful mental model.

22. UTF-8 vs OS Strings vs C Strings

The biggest conceptual difference is what representation the type guarantees.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
String / &str
    |
    +-- valid UTF-8


OsString / &OsStr
    |
    +-- OS-native representation
    +-- no UTF-8 guarantee


CString / &CStr
    |
    +-- NUL-terminated
    +-- no UTF-8 guarantee


PathBuf / &Path
    |
    +-- filesystem path semantics
    +-- OS-native representation

23. Rust String vs C String

This distinction is especially important when writing system software.

Rust

1
let text = String::from("hello");

Conceptually:

1
2
3
pointer
length
capacity

The length is known directly.

C

1
char *text = "hello";

Conceptually:

1
2
3
4
pointer
  |
  v
h e l l o \0

The string length is determined by searching for \0.

Therefore Rust needs:

1
CString

when interacting with APIs expecting this representation.

24. Rust String vs OS String

Do not assume that everything returned by the operating system is UTF-8.

For example:

1
2
3
4
5
6
7
use std::env;

let args = std::env::args_os();

for arg in args {
    println!("{arg:?}");
}

The arguments are:

1
OsString

because the OS may provide values that cannot be represented as valid UTF-8.

When you know that UTF-8 is required and acceptable:

1
2
3
4
5
let args = std::env::args();

for arg in args {
    println!("{arg}");
}

Here the arguments are:

1
String

25. Cow<str>

Another useful type when working with strings is:

1
std::borrow::Cow<'a, str>

Cow means:

Clone on Write.

It can either borrow existing string data or own a new String.

For example:

1
let value = os_string.to_string_lossy();

returns:

1
Cow<'_, str>

Conceptually:

1
2
3
4
5
Cow<str>
   |
   +── Borrowed(&str)
   |
   └── Owned(String)

If conversion does not require modification, it can borrow.

If conversion requires replacement of invalid UTF-8, it can create an owned String.

This avoids unnecessary allocation when possible.

26. String and Vec<u8>

A String is closely related to:

1
Vec<u8>

because UTF-8 text is ultimately stored as bytes.

You can get the bytes:

1
2
3
let text = String::from("hello");

let bytes = text.as_bytes();

The result is:

1
&[u8]

You can also consume a String and obtain its underlying bytes:

1
2
3
let text = String::from("hello");

let bytes: Vec<u8> = text.into_bytes();

This does not copy the allocation.

The ownership is transferred:

1
2
3
4
5
String
   |
   | into_bytes()
   v
Vec<u8>

27. Creating a String From Bytes

If you have bytes that you know are valid UTF-8:

1
2
3
4
5
let bytes = vec![104, 101, 108, 108, 111];

let text = String::from_utf8(bytes).unwrap();

println!("{text}");

from_utf8() returns:

1
Result<String, FromUtf8Error>

because arbitrary bytes are not necessarily valid UTF-8.

For a byte slice:

1
2
3
let bytes = &[104, 101, 108, 108, 111];

let text = std::str::from_utf8(bytes).unwrap();

This returns:

1
Result<&str, Utf8Error>

The distinction is:

1
2
3
4
5
Vec<u8>  -> String
          owned conversion

&[u8]    -> &str
          borrowed conversion

28. String Conversion Cheat Sheet

Common conversions include:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// &str -> String
let s = String::from("hello");

// &str -> String
let s = "hello".to_string();

// String -> &str
let s = String::from("hello");
let r: &str = &s;

// String -> Vec<u8>
let s = String::from("hello");
let bytes = s.into_bytes();

// &[u8] -> &str
let bytes = b"hello";
let s = std::str::from_utf8(bytes).unwrap();

// Vec<u8> -> String
let bytes = vec![104, 105];
let s = String::from_utf8(bytes).unwrap();

29. OS String Conversion Cheat Sheet

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
use std::ffi::{OsStr, OsString};

// &str -> OsString
let owned = OsString::from("hello");

// &OsStr -> OsString
let borrowed = OsStr::new("hello");
let owned = borrowed.to_os_string();

// OsString -> &OsStr
let owned = OsString::from("hello");
let borrowed: &OsStr = &owned;

// OsString -> String
let owned = OsString::from("hello");

match owned.into_string() {
    Ok(text) => println!("{text}"),
    Err(original) => println!("{original:?}"),
}

30. C String Conversion Cheat Sheet

1
2
3
4
5
6
7
8
9
10
11
12
13
use std::ffi::{CStr, CString};

// &str -> CString
let owned = CString::new("hello").unwrap();

// CString -> &CStr
let borrowed: &CStr = owned.as_c_str();

// &CStr -> &str
let text = borrowed.to_str().unwrap();

// CString -> raw pointer
let ptr = owned.as_ptr();

Remember:

1
CString::new(...)

can fail if the input contains an embedded NUL byte.

31. Path Conversion Cheat Sheet

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
use std::path::{Path, PathBuf};

// &str -> PathBuf
let owned = PathBuf::from("/tmp/file.txt");

// &str -> &Path
let borrowed = Path::new("/tmp/file.txt");

// PathBuf -> &Path
let path = PathBuf::from("/tmp/file.txt");
let borrowed: &Path = &path;

// Path -> &OsStr
let os_str = borrowed.as_os_str();

// PathBuf -> String-like display
println!("{}", path.display());

32. Which Type Should I Use?

A practical rule:

Normal application text

Use:

1
2
String
&str

For example:

1
2
3
fn greet(name: &str) {
    println!("Hello {name}");
}

Operating-system data

Use:

1
2
OsString
&OsStr

For example:

1
std::env::args_os()

Filesystem paths

Use:

1
2
PathBuf
&Path

For example:

1
2
3
fn open_config(path: &Path) {
    // ...
}

C FFI

Use:

1
2
CString
&CStr

For example:

1
2
3
unsafe {
    libc::puts(c_string.as_ptr());
}

33. A Useful Mental Model

Think about strings according to the boundary they are crossing.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
                         Rust application
                               |
                               |
                    UTF-8 text |
                               v
                         String / &str
                               |
              +----------------+----------------+
              |                                 |
              v                                 v
       Operating system                    C library
              |                                 |
              v                                 v
       OsString / &OsStr                 CString / &CStr
              |
              v
        PathBuf / &Path
        filesystem paths

This is much more useful than memorizing the types independently.

34. Systems Programming Perspective

When writing systems software, avoid converting everything to String.

For example, this is often unnecessary:

1
2
3
fn open_file(path: String) {
    // ...
}

A better API is:

1
2
3
fn open_file(path: &Path) {
    // ...
}

If you only need OS-level text:

1
2
3
fn process_argument(arg: &OsStr) {
    // ...
}

If you only need normal UTF-8 text:

1
2
3
fn process_name(name: &str) {
    // ...
}

If you need to call C:

1
2
3
fn call_c_api(name: &CStr) {
    // ...
}

Choosing the type according to the boundary makes invalid conversions and unnecessary allocations less likely.

35. Summary Table

TypeOwns dataUTF-8 guaranteedNUL terminatedMain purpose
StringYesYesNoOwned Rust text
&strNoYesNoBorrowed Rust text
OsStringYesNoNoOwned OS string
&OsStrNoNoNoBorrowed OS string
CStringYesNoYesOwned C string
&CStrNoNoYesBorrowed C string
PathBufYesNoNoOwned filesystem path
&PathNoNoNoBorrowed filesystem path
Cow<str>SometimesYesNoBorrowed-or-owned UTF-8 text

Key Takeaways

  • String is an owned, growable, valid UTF-8 string.
  • &str is a borrowed UTF-8 string slice.
  • OsString and &OsStr are for OS-native strings where UTF-8 is not guaranteed.
  • CString and &CStr are for C-compatible NUL-terminated strings.
  • PathBuf and &Path represent filesystem paths and are built on OS-string representations.
  • String and OsString are not interchangeable because OS strings may not be valid UTF-8.
  • CString is different from String because C uses NUL termination rather than Rust’s explicit length.
  • str, OsStr, CStr, and Path are borrowed/unsized views; their owned counterparts are String, OsString, CString, and PathBuf.
  • Use &str for ordinary borrowed UTF-8 text.
  • Use &OsStr when interacting with OS APIs.
  • Use &Path for filesystem paths.
  • Use &CStr when receiving borrowed C strings from FFI.
  • Do not convert everything to `String just because it is convenient; preserve the representation appropriate to the system boundary.

This post is licensed under CC BY 4.0 by the author.