Using Changeable Strings in Rust Programming Language: String, &str, and Mutable String Operations Guide

Using Changeable Strings in Rust

Using Changeable Strings in Rust

When I first started writing Rust, strings were the thing that confused me the most. Coming from languages like Python or JavaScript, I was used to just typing "hello" and moving on with my life. Rust had other plans for me. I remember staring at a compiler error that said something like “expected &str, found String” and genuinely not understanding why the language cared so much about the difference.

A few weeks (and a lot of trial and error) later, it clicked. Rust’s string system isn’t there to annoy you — it’s there to give you memory safety without a garbage collector, and once you understand why String and &str exist separately, everything else about Rust starts making a lot more sense too. In this article, I want to walk you through everything I wish someone had explained to me on day one: what String and &str actually are, how to mutate strings safely, how ownership and borrowing apply to text data, and the mistakes I made so you don’t have to.

What Exactly Is a String in Rust?

Rust actually has two primary string types that you’ll deal with constantly:

  1. String — an owned, growable, heap-allocated UTF-8 encoded string.
  2. &str (pronounced “string slice”) — a borrowed, immutable view into string data, which could live on the heap, the stack, or even be baked into your binary.

The reason Rust splits strings into these two types comes down to its ownership model. String owns its data and is responsible for cleaning it up. &str just borrows a look at some string data without taking responsibility for it.

Here’s the simplest possible comparison:

fn main() {
    let owned_string: String = String::from("Hello, Rust!");
    let borrowed_slice: &str = "Hello, Rust!";

    println!("{}", owned_string);
    println!("{}", borrowed_slice);
}

Output:

Hello, Rust!
Hello, Rust!

They print the same thing, but under the hood they’re completely different. owned_string lives on the heap and can grow or shrink. borrowed_slice is a string literal, baked directly into the compiled binary, and it can never change size.

Why Rust Doesn’t Have Just One String Type

I used to think this was over-engineering. It’s not. Consider what happens when you want a function to accept text without caring whether the caller owns a String or just has a &str:

fn greet(name: &str) {
    println!("Hello, {}!", name);
}

fn main() {
    let owned = String::from("Ayesha");
    let literal = "Bilal";

    greet(&owned);   // &String derefs to &str
    greet(literal);  // already a &str
}

Output:

Hello, Ayesha!
Hello, Bilal!

Because &str is the more general, flexible type, idiomatic Rust functions almost always accept &str as a parameter rather than String, even if the caller happens to have an owned string. This avoids unnecessary allocations and keeps your APIs flexible.

Creating and Growing a String

Since &str is immutable and fixed in size, if you want to build or modify text at runtime, you need String. Here’s how you typically construct one:

fn main() {
    let mut message = String::new();
    message.push_str("Rust");
    message.push(' ');
    message.push_str("is fun");
    message.push('!');

    println!("{}", message);
}

Output:

Rust is fun!

Notice the mut keyword. Without it, the compiler won’t let you call .push_str() or .push() at all — this is Rust enforcing mutability rules at compile time rather than letting you find out the hard way at runtime.

You can also build a String from formatted values using the format! macro, which works like println! but returns a String instead of printing:

fn main() {
    let name = "Zainab";
    let age = 27;
    let bio = format!("{} is {} years old.", name, age);

    println!("{}", bio);
}

Output:

Zainab is 27 years old.

Mutable String Operations

Here’s where things get practical. Let’s go through the operations I actually use in real projects.

Appending

fn main() {
    let mut sentence = String::from("Rust is");
    sentence.push_str(" powerful");
    sentence += " and safe.";
    println!("{}", sentence);
}

Output:

Rust is powerful and safe.

Notice the += operator works too, but only when the right-hand side is a &str. This is because String implements Add<&str>.

Inserting at a Position

fn main() {
    let mut greeting = String::from("Hello world");
    greeting.insert(5, ',');
    greeting.insert_str(6, " beautiful");
    println!("{}", greeting);
}

Output:

Hello, beautiful world

Removing and Truncating

fn main() {
    let mut text = String::from("Hello, Rust!");
    text.truncate(5);
    println!("{}", text);

    let mut word = String::from("Rustacean");
    let last_char = word.pop();
    println!("{:?} -> {}", last_char, word);
}

Output:

Hello
Some('n') -> Rustacea

Replacing Content

fn main() {
    let text = String::from("I love Python");
    let replaced = text.replace("Python", "Rust");
    println!("{}", replaced);
}

Output:

I love Rust

Note that .replace() doesn’t mutate the original string in place — it returns a brand new String. This trips up a lot of beginners who expect in-place mutation everywhere.

Ownership and Borrowing With Strings

This is the part that separates “I can write Rust code” from “I understand Rust.” Strings are one of the best ways to learn ownership because heap-allocated data forces the rules to matter.

fn main() {
    let s1 = String::from("ownership");
    let s2 = s1; // s1 is moved into s2

    // println!("{}", s1); // this would fail to compile
    println!("{}", s2);
}

When you write let s2 = s1;, Rust doesn’t copy the underlying heap data — it moves ownership from s1 to s2, and s1 becomes invalid. This is different from types like i32, which implement Copy and get duplicated instead of moved. Rust does this so that only one variable is ever responsible for freeing a given piece of heap memory, which eliminates entire categories of bugs like double frees and use-after-free errors that plague C and C++ programs.

If you actually want a duplicate, you call .clone() explicitly:

fn main() {
    let s1 = String::from("clone me");
    let s2 = s1.clone();

    println!("{} and {}", s1, s2);
}

Output:

clone me and clone me

Cloning is deliberately explicit in Rust because heap allocation isn’t free — the language wants you to see (and pay for) the cost of a deep copy in your source code rather than have it happen invisibly.

Borrowing Strings

Instead of transferring ownership, you can borrow a reference:

fn string_length(s: &String) -> usize {
    s.len()
}

fn main() {
    let my_string = String::from("Borrowing is safe");
    let length = string_length(&my_string);

    println!("'{}' has {} bytes", my_string, length);
}

Output:

'Borrowing is safe' has 18 bytes

Because string_length only borrows my_string (via &), ownership never moves, and main can keep using my_string afterward. This is the foundation of Rust’s borrow checker — it verifies at compile time that references never outlive the data they point to, and that you never have a mutable reference and an immutable one active at the same time.

Lifetimes and String Slices

String slices carry a lifetime, even when it’s invisible in simple code. Here’s a case where it becomes explicit:

fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
    if x.len() > y.len() { x } else { y }
}

fn main() {
    let string1 = String::from("Rust programming");
    let string2 = String::from("Rust");

    let result = longest(string1.as_str(), string2.as_str());
    println!("The longest string is: {}", result);
}

Output:

The longest string is: Rust programming

The 'a lifetime annotation tells the compiler: “the returned reference will live at least as long as both x and y.” Without it, the compiler can’t prove the returned reference is valid, because it has no way of knowing which input the return value borrows from.

UTF-8 and Why You Can’t Index a String Directly

One thing that surprises newcomers: you can’t do my_string[0] in Rust.

fn main() {
    let greeting = String::from("héllo");

    println!("Byte length: {}", greeting.len());
    println!("Char count: {}", greeting.chars().count());

    for c in greeting.chars() {
        print!("{} ", c);
    }
    println!();
}

Output:

Byte length: 6
Char count: 5
h é l l o 

Notice .len() returns 6, not 5 — because é takes two bytes in UTF-8. Rust strings are guaranteed valid UTF-8, and indexing by byte position could slice right through the middle of a multi-byte character, producing invalid data. Instead of allowing that footgun, Rust makes you iterate using .chars(), .bytes(), or use slicing ranges carefully with &greeting[0..1] (which will panic if it lands mid-character).

Real-World Application: Building a Simple Text Processor

Here’s a slightly bigger example showing strings used in a realistic way — a word counter:

fn word_count(text: &str) -> usize {
    text.split_whitespace().count()
}

fn to_title_case(text: &str) -> String {
    text.split_whitespace()
        .map(|word| {
            let mut chars = word.chars();
            match chars.next() {
                Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
                None => String::new(),
            }
        })
        .collect::<Vec<_>>()
        .join(" ")
}

fn main() {
    let paragraph = "rust is a systems programming language";

    println!("Word count: {}", word_count(paragraph));
    println!("Title case: {}", to_title_case(paragraph));
}

Output:

Word count: 7
Title case: Rust Is A Systems Programming Language

This kind of pattern — borrowing input as &str, returning an owned String when you need new data — is extremely idiomatic and something you’ll see throughout real Rust codebases.

Performance Considerations

Because String is heap-allocated, every push_str or += operation may trigger reallocation if the current buffer runs out of capacity. If you know roughly how large your final string will be, preallocate it:

fn main() {
    let mut buffer = String::with_capacity(100);
    for i in 0..5 {
        buffer.push_str(&format!("Line {}\n", i));
    }
    print!("{}", buffer);
}

Output:

Line 0
Line 1
Line 2
Line 3
Line 4

String::with_capacity() avoids repeated reallocations, which matters a lot in hot loops or when processing large text files.

Common Mistakes I Made (So You Can Skip Them)

Cargo Commands You’ll Actually Use

cargo new string_playground
cd string_playground
cargo run
cargo build --release

cargo run compiles and runs your code in one step during development, while cargo build --release produces an optimized binary for production use.

FAQs

Q: When should I use String vs &str? Use &str for function parameters and read-only access. Use String when you need to own, build, or mutate text, or when you need the data to outlive the current scope.

Q: Is &str always faster than String? Not inherently — &str avoids allocation, but String is necessary whenever you actually need to build or modify text. The real performance win is avoiding unnecessary clones and allocations, not avoiding String altogether.

Q: Why can’t I mutate a string literal? String literals are &'static str, embedded directly in the compiled binary as read-only data. There’s no heap buffer to grow, so mutation isn’t possible without converting it into an owned String first.

Q: How do I convert between String and &str? Use .as_str() or &my_string[..] to go from String to &str, and .to_string() or String::from() to go the other way.

Troubleshooting Tips

Summary

Strings in Rust look intimidating at first, but the split between String and &str is really just Rust being upfront about ownership, memory allocation, and safety — things other languages hide from you until they cause a bug. Once you internalize that String owns growable heap data and &str is a borrowed view into text, the rest of the API — pushing, inserting, slicing, formatting — becomes predictable and even pleasant to work with. Mastering strings early pays off, because the same ownership and borrowing rules apply throughout the rest of Rust.

References

Exit mobile version