Go to Rust Series: ← Mut vs Immutable | Series Overview | Option and Result →


The Confusion

Go has one string type:

var s string = "hello"

Rust has two main string types:

let s1: String = String::from("hello");  // Owned string
let s2: &str = "hello";                  // String slice

Why two types? It’s one of the most confusing aspects for Go developers learning Rust.

Go: One String Type

Go:

package main

import "fmt"

func main() {
    // All of these are just "string"
    s1 := "hello"
    s2 := "world"
    s3 := s1 + " " + s2

    printString(s1)
    printString(s3)
}

func printString(s string) {
    fmt.Println(s)
}

Simple. Everything is string.

Under the hood: Go’s string is a struct with a pointer and length:

type stringStruct struct {
    str unsafe.Pointer  // Pointer to bytes
    len int             // Length
}

But you never think about this. It just works.

Rust: String vs &str

Rust has:

  1. String - Owned, mutable, heap-allocated
  2. &str - Borrowed, immutable, view into string data

Rust:

fn main() {
    let s1: String = String::from("hello");  // Owned (heap)
    let s2: &str = "world";                  // Borrowed (static or stack)
    let s3: String = format!("{} {}", s1, s2);  // Owned

    print_str(&s1);  // Pass String as &str
    print_str(s2);   // Already &str
}

fn print_str(s: &str) {
    println!("{}", s);
}

When to Use Each

Use String when:

  • You need to own the data
  • You need to modify the string
  • You’re building or accumulating text
  • You’re returning a string from a function

Example:

fn create_greeting(name: &str) -> String {
    format!("Hello, {}!", name)  // Returns owned String
}

Use &str when:

  • You’re only reading the string
  • You don’t need to own the data
  • You want to reference part of another string
  • Function parameters (usually)

Example:

fn print_message(msg: &str) {  // Borrow, don't own
    println!("{}", msg);
}

Memory Representation

Go: Always Heap-Backed (Mostly)

Go:

s := "hello"  // Pointer to static string data
s2 := s + " world"  // New allocation on heap

Go’s strings point to immutable byte arrays.

Rust: String (Heap) vs &str (Various)

String:

let s = String::from("hello");
// Memory layout:
// Stack: [ptr, len, capacity]
// Heap: [h, e, l, l, o]

&str (string literal):

let s: &str = "hello";
// Memory layout:
// Stack: [ptr, len]
// Static memory: [h, e, l, l, o]

&str (slice of String):

let owned = String::from("hello world");
let slice: &str = &owned[0..5];  // "hello"
// Stack: [ptr to owned data, len=5]

String Creation

Go: Simple

Go:

s1 := "literal"
s2 := string([]byte{'h', 'i'})
s3 := fmt.Sprintf("Value: %d", 42)

Rust: Different Methods

Rust:

// String literals are &str
let s1: &str = "literal";

// Create owned String
let s2: String = String::from("owned");
let s3: String = "owned".to_string();
let s4: String = "owned".to_owned();

// From bytes
let s5: String = String::from_utf8(vec![104, 105]).unwrap();

// Format
let s6: String = format!("Value: {}", 42);

String Concatenation

Go: Easy with +

Go:

s1 := "hello"
s2 := " world"
s3 := s1 + s2  // New string

Rust: Multiple Ways

Rust (with String):

let s1 = String::from("hello");
let s2 = String::from(" world");

// Method 1: + operator (consumes s1!)
let s3 = s1 + &s2;  // s1 is moved!
// Can't use s1 anymore

// Method 2: format! macro
let s1 = String::from("hello");
let s3 = format!("{}{}", s1, s2);  // s1 still usable

// Method 3: push_str
let mut s1 = String::from("hello");
s1.push_str(" world");  // Mutates s1

Gotcha: The + operator moves the left side:

let s1 = String::from("hello");
let s2 = String::from(" world");
let s3 = s1 + &s2;
println!("{}", s1);  // ERROR! s1 was moved

Function Parameters: The Golden Rule

In Rust, prefer &str for parameters:

Bad (forces callers to own String):

fn greet(name: String) {  // Takes ownership
    println!("Hello, {}", name);
}

fn main() {
    let name = String::from("Alice");
    greet(name);
    // Can't use name anymore!
}

Good (accepts both String and &str):

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

fn main() {
    let name = String::from("Alice");
    greet(&name);  // Still can use name
    greet("Bob");  // Can pass string literal
}

Go equivalent (always works):

func greet(name string) {
    fmt.Printf("Hello, %s\n", name)
}

Return Values: String or &str?

Go: Always string

Go:

func createMessage() string {
    return "New message"
}

func getFirst(s string) string {
    return s[0:5]  // Still returns string
}

Rust: Depends on Ownership

Return String (owned):

fn create_message() -> String {
    String::from("New message")  // Caller owns result
}

Return &str (borrowed):

fn get_first(s: &str) -> &str {
    &s[0..5]  // Borrow from input
}

Can’t return &str from owned String:

fn create_slice() -> &str {
    let s = String::from("hello");
    &s  // ERROR! s is dropped at end of function
}

Converting Between Types

Go: No Conversion Needed

Go:

s := "hello"
s2 := s  // Same type

Rust: Explicit Conversions

&str → String:

let s: &str = "hello";
let owned: String = s.to_string();
let owned2: String = s.to_owned();
let owned3: String = String::from(s);

String → &str:

let s: String = String::from("hello");
let borrowed: &str = &s;  // Deref coercion
let borrowed2: &str = s.as_str();  // Explicit

Automatic coercion in function calls:

fn takes_str(s: &str) {
    println!("{}", s);
}

let owned = String::from("hello");
takes_str(&owned);  // Automatically converts String -> &str

String Slicing

Go: Returns string

Go:

s := "hello world"
slice := s[0:5]  // "hello" (type: string)

Rust: Returns &str

Rust:

let s = String::from("hello world");
let slice: &str = &s[0..5];  // "hello"

With string literal:

let s: &str = "hello world";
let slice: &str = &s[0..5];  // "hello"

Common Patterns

Pattern 1: Building Strings

Go:

var builder strings.Builder
builder.WriteString("hello")
builder.WriteString(" ")
builder.WriteString("world")
result := builder.String()

Rust:

let mut s = String::new();
s.push_str("hello");
s.push_str(" ");
s.push_str("world");
// s is the result

Or:

let parts = vec!["hello", "world"];
let result = parts.join(" ");  // Returns String

Pattern 2: Reading Lines from File

Go:

file, _ := os.Open("file.txt")
scanner := bufio.NewScanner(file)
for scanner.Scan() {
    line := scanner.Text()  // Returns string
    processLine(line)
}

Rust:

use std::fs::File;
use std::io::{BufRead, BufReader};

let file = File::open("file.txt")?;
let reader = BufReader::new(file);

for line in reader.lines() {
    let line: String = line?;  // Returns String (owned)
    process_line(&line);  // Pass as &str
}

Pattern 3: Accepting Multiple String Types

Go:

func process(s string) {  // Only accepts string
    // ...
}

Rust:

fn process<S: AsRef<str>>(s: S) {  // Accepts String, &str, etc.
    let s_ref: &str = s.as_ref();
    // ...
}

// Or simpler:
fn process(s: &str) {  // Most common pattern
    // ...
}

String Iteration

Go: Iterate Runes

Go:

s := "hello"
for i, ch := range s {
    fmt.Printf("%d: %c\n", i, ch)
}

Rust: Chars or Bytes

Rust (chars):

let s = "hello";
for ch in s.chars() {
    println!("{}", ch);
}

Rust (bytes):

let s = "hello";
for byte in s.bytes() {
    println!("{}", byte);
}

Rust (with indices):

let s = "hello";
for (i, ch) in s.char_indices() {
    println!("{}: {}", i, ch);
}

Common Errors

Error 1: Trying to Modify &str

Rust:

let s: &str = "hello";
s.push_str(" world");  // ERROR! &str is immutable

Fix:

let mut s = String::from("hello");
s.push_str(" world");  // OK

Error 2: Returning Dangling &str

Rust:

fn create() -> &str {
    let s = String::from("hello");
    &s  // ERROR! s is dropped
}

Fix:

fn create() -> String {
    String::from("hello")  // Return owned
}

Error 3: Concatenating Wrong Types

Rust:

let s1: &str = "hello";
let s2: &str = " world";
let s3 = s1 + s2;  // ERROR! Can't add two &str

Fix:

let s3 = format!("{}{}", s1, s2);  // Returns String
// or
let s3 = s1.to_string() + s2;

The Mental Model

Go:

  • One string type
  • Simple and consistent
  • Copy-on-write semantics
  • GC handles memory

Rust:

  • String = owned (heap-allocated, growable)
  • &str = borrowed view (immutable reference)
  • Choose based on ownership needs
  • No GC, explicit management

Conclusion

Aspect Go Rust
Types string String and &str
Simplicity Very simple More complex
Ownership Hidden Explicit
Parameters string Prefer &str
Returns string String or &str
Concatenation + operator Multiple methods

Rule of thumb for Rust:

  • Function parameters: Use &str (accepts both types)
  • Return owned data: Use String
  • Return borrowed data: Use &str (with lifetime)
  • Building strings: Use String with push_str or format!

Next: Understanding Option<T> and Result<T> - Rust’s approach to handling absence and errors.


Go to Rust Series: ← Mut vs Immutable | Series Overview | Option and Result →


String verdict: Go’s single string type is simpler. Rust’s two types are confusing at first but provide better control and performance.