String vs str in Rust: Understanding the difference

If you have a background in a different programming language and you are learning Rust, one of the first questions you might ask is: What is the difference between a String and a str in Rust? It is easy to let our brains fool ourselves as it would typically relate a str with the word “string”. Hence, it is easy to get confused with str as String, but is a String a . Hopefully, this article has everything you need to differentiate between these two types in Rust.

What is str?

A str is a primitive type in Rust, and it represents a string literal, and it’s data is allocated in the data segment of the application binary.

What is a string literal?

A string literal is a string slice or a sequence of characters enclosed by double quotes ("").

What is a string slice?

A slice represents a view containing a sequence of elements and it is represented with the syntax [T]. Slices don’t have ownership, but they let you reference the sequence of elements.

Hence, a string slice is the reference of a sequence of elements of a string.

let my_string = String::from("Learning Rust");

let my_slice = &my_string[0..8];

Notice in the previous example how we get the reference of a String, which is the collection. This might not be that clear at first. That’s why we will break down the my_string variable.

The my_string variable contains the characters Learning Rust. Each character has a place or index in the String, which is a growable array.

String is a growable array

Hence, when using &my_string[0..8], Rust gets the reference of the pointer of the String my_string between 0 and 8, which results in the sequence Learning.

Although a string slice often represents a subsequence of elements of String, that doesn’t prevent from getting the whole sequence of elements of a String.

let my_string = String::from("Learning Rust");

let my_slice = &my_string[..];

// the following are equivalent to the above statement
let my_slice = &my_string[..];
let my_slice = &my_string[0..13];
let my_slice = &my_string[..13];

In this case, the my_slice variable cointains the characters Learning Rust.

Common to find a str in its borrowed state

Typically, the str is found preceded by &, which in other words is &str or the borrowing state of a str in Rust. For example, whenever we generate a str and assign it to a variable, the variable is a &str.

let country = "Colombia";

// this is the equivalent of the above statement
let country: &str = "Colombia";

This might be confusing at first as you might think the variable country is a str. However, there is not a way to have a str variable without the & or in its borrowed state as str(s) are string slices, and slices don’t have ownership as previously mentioned. Hence, the str itself is Colombia which is already a sequence of elements or a string slice. However, the country variable holds the reference of Colombia, which is allocated in the application binary.

What is a String?

A String is a struct containing a vector Vec, or growable 8-bit unsigned array like you can see in the snippet of code below. You can also find the struct definition by checking the Rust open source library.

pub struct String {
    vec: Vec<u8>
}

Contrary to str, a String has ownerhip of the data, which means it is not necessary to use & or borrowing state when defining the value of a String to a variable.

let my_string = String::from("Understanding the String concept?");

// this is the equivalent of the above statement
let my_string: String = String::from("Understanding the String concept?");

The size of a String can be known or unknown at compile time during its initialization, but it can grow as the length of the String reaches its capacity. For instance, in the previous example my_string didn’t have a known capacity until Rust figures out how many characters are in the string sequence.

let mut my_string = String::from("Understanding the String concept?"); // capacity is 33, length is 33
 my_string = "Hello!".to_string(); // capacity is 6, length is 6

However, in the following example, we define the capacitity when initilizing the String using with_capacity.

let mut my_string = String::with_capacity(3);
my_string.push('a'); // capacity is 3, length is 1
my_string.push('b'); // capacity is 3, length is 2
my_string.push('c'); // capacity is 3, length is 3
my_string.push('d'); // capacity could double, length is 4

Everytime the capacity is updated, data needs to be reallocated which results in an expensive operation.

What are the differences between str and String?

Hopefully, you noticed some differences when learning the definition of a str and a String. However, the following table shows side-by-side key differences between these two types.

strString
Primitive TypeBuilt-in struct
Doesn’t have ownership of the string as it is typically used by referenceHas ownership of the string
It is a string sliceIt is a growable array
Size known at compile timeSize is unknown at compile time
Data allocated in the data segment of the application binaryData allocated in a heap
Uses & or reference to assign a str value to a variable Not need need to use & or reference to assign a String value to a variable
Comparison between str and String

Understanding the difference can have an impact in the performance and behavior of the program

While Rust programs typically outperform other applications developed in different programming languages, that doesn’t mean we shouldn’t be aware of what to look for to our Rust application better.

For example, a str is often used in its borrowed state as function parameters.

fn main() {
    let my_string = String::from("Understanding the String concept?");
 
    print_data(&my_string);
}

fn  print_data(data: &str) {
   println!("printing my data {} ", data);
}

Note: it might confusing at first to see we are passing the reference of a String when calling print_data function when data parameter expects a &str. A &String can automatically convert into a &str.

This allows us to pass only the reference of the String to the print_data function and remain accessible inside the main function as the String is not moved. In other words, the term moved means to transfer ownership.

Let’s take a look at another example. In this case, the code won’t compile.

fn main() {
    let my_string = String::from("Understanding the String concept?");
 
    print_data(my_string); // ownership of my_string is transfered

    print!("printing inside main {}", my_string); // error at compile time here 
}

fn  print_data(data: String) {
    println!("printing my data {} ", data);
}

Notice there are couple of changes from the previous example:

  1. The print_data function accepts a data parameter of a type String
  2. Attempt to print the values of my_string inside the main function

The reason why that code won’t compile is the String my_string initial ownership was in the main function. However, since the print_data functions accepts as a parameter the String and not a reference of a string, the ownership will be transfered to print_data function, making my_string no longer available in the main function.

To fix this, we modify the type of data to accept a reference of a String instead. This will force use pass the reference of my_string when calling the print_data function.

fn main() {
    let my_string = String::from("Understanding the String concept?");
 
    print_data(&my_string); // my_string is borrowed

    print!("printing inside main {}", my_string); 
}

fn  print_data(data: &String) {
    println!("printing my data {} ", data);
}

You might think: Why would I want to have the data parameter defined between &str or &String?

// what to choose?
// 1. data: &String 
fn  print_data(data: &String) {
    println!("printing my data {} ", data);
}

// 2. or data: &str
fn  print_data(data: &str) {
   println!("printing my data {} ", data);
}

Making a decision between the two options in a small program like this will probably not matter much. However, it is all about understanding the fundamentals of the programming language. We will analyze each option.

Let’s start analyzing option 1, or using &String as the type of data parameter. Remember when we used this alternative, the variable my_string in the main was a String type.

fn main() {
    let my_string = String::from("Understanding the String concept?");
 
    print_data(&my_string);

    print!("printing inside main {}", my_string); 
}

fn  print_data(data: &String) {
    println!("printing my data {} ", data);
}

We only had to pass the reference my_string to trigger print_data function, which means my_string still has the ownership of the data in a heap.

However, what if we make a change in the code, and instead of defining a my_string variable, we define a my_str variable, where my_str has a type of &str?

Assume we are going to call the print_data function using the borrowed data of my_str. However, it wouldn’t be possible to do this operation unless we generate a String off of the string literal borrowed in my_str, as print_data only accepts data parameter of type &String.

fn main() {
    let my_str = "This is a str";

    // converting the str to String is an expensive operation
    print_data(&my_str.to_string()); 
 
    print!("printing inside main {}", my_str); 
}

fn  print_data(data: &String) {
    println!("printing my data {} ", data);
}

When using &my_str.to_string(), data is still passed by reference (&). However, there’s also data allocation in a heap as we make it a String, which is itself an expensive operation when compared to only passing the reference.

Let’s look at option 2, or using &str as the type of data parameter. We are going to start with using my_string variable which has a type of String.

fn main() {
    let my_string = String::from("Understanding the String concept?");
 
    print_data(&my_string);

    print!("printing inside main {}", my_string); 
}

fn  print_data(data: &str) {
    println!("printing my data {} ", data);
}

Notice we only needed to pass the borrowed state of my_string to print_data as String can convert to a string slice automatically. This process requires only to pass the reference. At this point we haven’t allocated any additional data in the heap besides the string literal stored in my_string.

Now, let’s change my_string to use a &str variable called my_str.

 fn main() {
    let my_str = "This is a str";
 
    print_data(my_str);

    print!("printing inside main {}", my_str); 
}

fn  print_data(data: &str) {
    println!("printing my data {} ", data);
}

In this case, we are only passing the reference of my_str.

Notice how there’s an impact in performance when using data: &String or data: &str. Typically, the type &str is useful when passed as a function parameter as there is no need to create a copy into another String .

However, that doesn’t mean you should always have function parameters with the type of &str. It all comes down to what you are intending to achieve in your Rust program.

Conclusion

All in all, understanding the differences between a str and a String is a challenge itself. Knowing a str is a primitive type and a String is a built-in struct, doesn’t help much when deciding what to choose in our code.

Also, learning the differences between the two allows us to get a deeper understanding of how the Rust programming language works and getting a good grasp of concepts such as ownership, which main purpose is to optimize memory management. This can make a difference in the performance of your program, even when making little changes in the code.

Was this article helpful?

I hope this article helped you to clarify doubts and concepts of Rust, especially to those new to the programming language.

Share your thoughts by replying on Twitter of Become A Better Programmer or to personal my Twitter account.