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:
String- Owned, mutable, heap-allocated&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
Stringwithpush_strorformat!
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.