| name | convert-golang-rust |
| description | Convert Go code to idiomatic Rust. Use when migrating Go projects to Rust, translating Go patterns to idiomatic Rust, or refactoring Go codebases. Extends meta-convert-dev with Go-to-Rust specific patterns. |
Convert Go to Rust
Convert Go code to idiomatic Rust. This skill extends meta-convert-dev with Go-to-Rust specific type mappings, idiom translations, and tooling.
This Skill Extends
meta-convert-dev- Foundational conversion patterns (APTV workflow, testing strategies)
For general concepts like the Analyze → Plan → Transform → Validate workflow, testing strategies, and common pitfalls, see the meta-skill first.
This Skill Adds
- Type mappings: Go types → Rust types
- Idiom translations: Go patterns → idiomatic Rust
- Error handling: Go error interface → Rust Result<T, E>
- Async patterns: Goroutines/channels → Tokio async/await
- Memory/Ownership: Garbage collection → ownership/borrowing
- Interface patterns: Go interface → Rust trait
This Skill Does NOT Cover
- General conversion methodology - see
meta-convert-dev - Go language fundamentals - see
lang-go-dev - Rust language fundamentals - see
lang-rust-dev - Reverse conversion (Rust → Go) - see
convert-rust-golang
Quick Reference
| Go | Rust | Notes |
|---|---|---|
string |
String / &str |
Owned vs borrowed |
int |
i32 / i64 |
Specify size explicitly |
uint |
u32 / u64 |
Unsigned variants |
float64 |
f64 |
Direct mapping |
bool |
bool |
Direct mapping |
[]T |
Vec<T> |
Owned slice |
[N]T |
[T; N] |
Fixed-size array |
map[K]V |
HashMap<K, V> |
Hash table |
chan T |
mpsc::Sender<T> / Receiver<T> |
Channels |
interface{} |
Generic with trait bounds | Type-safe alternatives |
nil |
None in Option<T> |
Explicit nullability |
error |
Result<T, E> |
Type-safe errors |
struct |
struct |
Similar syntax |
interface |
trait |
Behavioral contracts |
defer |
RAII / Drop trait |
Automatic cleanup |
When Converting Code
- Analyze source thoroughly before writing target
- Map types first - create type equivalence table
- Preserve semantics over syntax similarity
- Adopt target idioms - don't write "Go code in Rust syntax"
- Handle edge cases - nil checks, error paths, resource cleanup
- Test equivalence - same inputs → same outputs
Type System Mapping
Primitive Types
| Go | Rust | Notes |
|---|---|---|
bool |
bool |
Direct mapping |
string |
String |
Owned, heap-allocated UTF-8 |
string (param) |
&str |
Borrowed string slice for parameters |
int |
isize |
Platform-dependent signed integer |
int8 |
i8 |
8-bit signed |
int16 |
i16 |
16-bit signed |
int32 / rune |
i32 |
32-bit signed |
int64 |
i64 |
64-bit signed |
uint |
usize |
Platform-dependent unsigned |
uint8 / byte |
u8 |
8-bit unsigned |
uint16 |
u16 |
16-bit unsigned |
uint32 |
u32 |
32-bit unsigned |
uint64 |
u64 |
64-bit unsigned |
float32 |
f32 |
32-bit float |
float64 |
f64 |
64-bit float |
complex64 |
- | Use external crate (num-complex) |
complex128 |
- | Use external crate (num-complex) |
Collection Types
| Go | Rust | Notes |
|---|---|---|
[]T |
Vec<T> |
Growable, owned array |
[]T (param) |
&[T] |
Borrowed slice for parameters |
[N]T |
[T; N] |
Fixed-size array on stack |
map[K]V |
HashMap<K, V> |
Hash table, K must be Hash + Eq |
map[K]V (ordered) |
BTreeMap<K, V> |
Ordered map, K must be Ord |
| Set (manual) | HashSet<T> |
Deduplicated collection |
| Set (ordered) | BTreeSet<T> |
Ordered deduplicated collection |
Composite Types
| Go | Rust | Notes |
|---|---|---|
struct { ... } |
struct { ... } |
Similar syntax, explicit visibility |
*T |
Box<T> |
Heap allocation, single owner |
*T (shared) |
Rc<T> / Arc<T> |
Reference counted (single/multi-threaded) |
interface { ... } |
trait Trait { ... } |
Behavior definition |
struct + methods |
impl Block |
Method implementation |
func(T) U |
fn(T) -> U |
Function type |
func(T) U (closure) |
Fn(T) -> U |
Closure trait |
chan T |
mpsc::Sender<T> |
Channel sender |
<-chan T |
mpsc::Receiver<T> |
Channel receiver |
chan<- T |
mpsc::Sender<T> |
Send-only channel |
Pointer and Reference Types
| Go | Rust | Notes |
|---|---|---|
*T (nullable) |
Option<Box<T>> |
Nullable heap pointer |
*T (non-null) |
Box<T> |
Non-null owned heap pointer |
&T (for mutation) |
&mut T |
Exclusive mutable reference |
| Pointer to slice | &[T] |
Slice reference |
| Pointer to map | &HashMap<K, V> |
Map reference |
Idiom Translation
Pattern 1: Error Handling with Multiple Returns
Go:
func readFile(path string) ([]byte, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("failed to read %s: %w", path, err)
}
return data, nil
}
Rust:
use std::fs;
use std::path::Path;
fn read_file(path: &Path) -> Result<Vec<u8>, std::io::Error> {
fs::read(path)
.map_err(|e| std::io::Error::new(
e.kind(),
format!("failed to read {}: {}", path.display(), e)
))
}
Why this translation:
- Rust's
Result<T, E>encodes success/failure in the type system - The
?operator propagates errors ergonomically - Error wrapping uses
map_errinstead of manual checks - Borrowed
&Pathinstead of ownedStringfor efficiency
Pattern 2: Nil Checking
Go:
func getUserName(user *User) string {
if user == nil {
return "Anonymous"
}
if user.Name == "" {
return "Anonymous"
}
return user.Name
}
Rust:
fn get_user_name(user: Option<&User>) -> &str {
user.and_then(|u| {
if u.name.is_empty() {
None
} else {
Some(u.name.as_str())
}
})
.unwrap_or("Anonymous")
}
// Or more idiomatically with pattern matching:
fn get_user_name(user: Option<&User>) -> &str {
match user {
Some(u) if !u.name.is_empty() => &u.name,
_ => "Anonymous",
}
}
Why this translation:
Option<T>makes nullability explicit in the type system- Combinators like
and_thenandunwrap_orare idiomatic - Pattern matching with guards is more readable
- Borrowed references avoid unnecessary cloning
Pattern 3: Interface Implementation
Go:
type Reader interface {
Read(p []byte) (n int, err error)
}
type FileReader struct {
path string
}
func (f *FileReader) Read(p []byte) (int, error) {
// Implementation
return len(p), nil
}
func processReader(r Reader) error {
buf := make([]byte, 1024)
n, err := r.Read(buf)
if err != nil {
return err
}
// Process n bytes
return nil
}
Rust:
use std::io::{self, Read};
trait Reader {
fn read(&mut self, buf: &mut [u8]) -> io::Result<usize>;
}
struct FileReader {
path: String,
}
impl Reader for FileReader {
fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
// Implementation
Ok(buf.len())
}
}
fn process_reader<R: Reader>(r: &mut R) -> io::Result<()> {
let mut buf = vec![0u8; 1024];
let n = r.read(&mut buf)?;
// Process n bytes
Ok(())
}
Why this translation:
- Rust traits are explicitly implemented with
impl Trait for Type - Generic functions use trait bounds (
<R: Reader>) - Mutable borrows (
&mut) make mutation explicit - The
?operator replaces verbose error checking
Pattern 4: Goroutines and Channels
Go:
func processItems(items []string) []string {
results := make(chan string, len(items))
for _, item := range items {
go func(s string) {
results <- processItem(s)
}(item)
}
var processed []string
for i := 0; i < len(items); i++ {
processed = append(processed, <-results)
}
return processed
}
Rust:
use tokio::task;
async fn process_items(items: Vec<String>) -> Vec<String> {
let handles: Vec<_> = items
.into_iter()
.map(|item| {
task::spawn(async move {
process_item(item).await
})
})
.collect();
let mut processed = Vec::new();
for handle in handles {
if let Ok(result) = handle.await {
processed.push(result);
}
}
processed
}
Why this translation:
- Tokio's async/await is more explicit about async boundaries
task::spawncreates async tasks similar to goroutines- Awaiting task handles is more type-safe than channels
- Move semantics avoid accidental captures
- Alternative: Use channels with
tokio::sync::mpscfor Go-like patterns
Pattern 5: Defer for Cleanup
Go:
func processFile(path string) error {
f, err := os.Open(path)
if err != nil {
return err
}
defer f.Close()
// Work with file
return process(f)
}
Rust:
use std::fs::File;
use std::path::Path;
fn process_file(path: &Path) -> std::io::Result<()> {
let f = File::open(path)?;
// Work with file
process(&f)
// f.close() called automatically via Drop
}
// Or with explicit scope control:
fn process_file_scoped(path: &Path) -> std::io::Result<()> {
let result = {
let f = File::open(path)?;
process(&f)
}; // f dropped here
result
}
Why this translation:
- Rust's RAII (Drop trait) automatically cleans up resources
- No need for explicit
deferstatements - Scope-based cleanup is deterministic
- More type-safe than runtime defer
Pattern 6: Type Assertions and Type Switches
Go:
func handleValue(v interface{}) string {
switch val := v.(type) {
case string:
return fmt.Sprintf("string: %s", val)
case int:
return fmt.Sprintf("int: %d", val)
case error:
return fmt.Sprintf("error: %v", val)
default:
return "unknown"
}
}
Rust:
// Use enums instead of interface{} for type-safe variants
enum Value {
String(String),
Int(i32),
Error(String),
}
fn handle_value(v: Value) -> String {
match v {
Value::String(s) => format!("string: {}", s),
Value::Int(i) => format!("int: {}", i),
Value::Error(e) => format!("error: {}", e),
}
}
// Or use trait objects with downcasting (less idiomatic):
use std::any::Any;
fn handle_any(v: &dyn Any) -> String {
if let Some(s) = v.downcast_ref::<String>() {
format!("string: {}", s)
} else if let Some(i) = v.downcast_ref::<i32>() {
format!("int: {}", i)
} else {
"unknown".to_string()
}
}
Why this translation:
- Rust enums are type-safe alternatives to
interface{} - Pattern matching exhaustively handles all variants
- Compiler ensures all cases are covered
- Avoid
Anydowncasting when possible (use enums instead)
Pattern 7: Method Receivers (Value vs Pointer)
Go:
type Counter struct {
count int
}
// Pointer receiver (mutates)
func (c *Counter) Increment() {
c.count++
}
// Value receiver (doesn't mutate)
func (c Counter) Value() int {
return c.count
}
Rust:
struct Counter {
count: i32,
}
impl Counter {
// Mutable reference (mutates)
fn increment(&mut self) {
self.count += 1;
}
// Immutable reference (doesn't mutate)
fn value(&self) -> i32 {
self.count
}
// Consuming method (takes ownership)
fn into_inner(self) -> i32 {
self.count
}
}
Why this translation:
- Rust makes mutability explicit:
&self,&mut self,self - Borrowing prevents accidental copies
- Consuming methods (
self) transfer ownership - More explicit control over mutation
Pattern 8: Struct Embedding (Composition)
Go:
type Base struct {
ID int
}
func (b *Base) GetID() int {
return b.ID
}
type Derived struct {
Base
Name string
}
func main() {
d := Derived{
Base: Base{ID: 1},
Name: "test",
}
fmt.Println(d.GetID()) // Method promoted from Base
}
Rust:
struct Base {
id: i32,
}
impl Base {
fn get_id(&self) -> i32 {
self.id
}
}
struct Derived {
base: Base,
name: String,
}
impl Derived {
// Explicit delegation (no automatic promotion)
fn get_id(&self) -> i32 {
self.base.get_id()
}
}
// Or use Deref trait for automatic field access:
use std::ops::Deref;
impl Deref for Derived {
type Target = Base;
fn deref(&self) -> &Self::Target {
&self.base
}
}
Why this translation:
- Rust favors explicit composition over embedding
- No automatic method promotion (use delegation)
Dereftrait can provide field access convenience- More explicit about the relationship
Pattern 9: Context Propagation
Go:
func fetchData(ctx context.Context, url string) ([]byte, error) {
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
if err != nil {
return nil, err
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
return io.ReadAll(resp.Body)
}
Rust:
use tokio::time::{timeout, Duration};
use reqwest;
async fn fetch_data(url: &str, timeout_ms: u64) -> Result<Vec<u8>, reqwest::Error> {
let response = timeout(
Duration::from_millis(timeout_ms),
reqwest::get(url)
)
.await
.map_err(|_| reqwest::Error::from(std::io::Error::new(
std::io::ErrorKind::TimedOut,
"request timed out"
)))??;
response.bytes()
.await
.map(|b| b.to_vec())
}
// Or with explicit cancellation token:
use tokio_util::sync::CancellationToken;
async fn fetch_data_cancellable(
url: &str,
cancel_token: CancellationToken,
) -> Result<Vec<u8>, reqwest::Error> {
tokio::select! {
result = reqwest::get(url) => {
result?.bytes().await.map(|b| b.to_vec())
}
_ = cancel_token.cancelled() => {
Err(reqwest::Error::from(std::io::Error::new(
std::io::ErrorKind::Interrupted,
"cancelled"
)))
}
}
}
Why this translation:
- Rust doesn't have built-in context; use timeout or cancellation tokens
tokio::select!provides cancellation semantics- Explicit timeout durations instead of context deadlines
- More type-safe cancellation handling
Pattern 10: Variadic Functions
Go:
func sum(nums ...int) int {
total := 0
for _, n := range nums {
total += n
}
return total
}
func main() {
fmt.Println(sum(1, 2, 3, 4))
}
Rust:
fn sum(nums: &[i32]) -> i32 {
nums.iter().sum()
}
fn main() {
println!("{}", sum(&[1, 2, 3, 4]));
}
// Or with macro for syntax sugar:
macro_rules! sum {
($($x:expr),*) => {
{
let nums = [$($x),*];
nums.iter().sum::<i32>()
}
};
}
fn main() {
println!("{}", sum!(1, 2, 3, 4));
}
Why this translation:
- Rust doesn't have variadic functions; use slices
- Macros can provide variadic-like syntax
- Iterator methods are more idiomatic than loops
- More explicit about allocation
Error Handling
Go Error Interface → Rust Result Type
Go's error interface and multiple return values translate to Rust's Result<T, E> enum.
Philosophy Shift:
- Go: Errors are values, checked explicitly via
if err != nil - Rust: Errors are types, propagated with
?operator
Basic Error Translation
Go:
func divide(a, b float64) (float64, error) {
if b == 0 {
return 0, errors.New("division by zero")
}
return a / b, nil
}
func main() {
result, err := divide(10, 0)
if err != nil {
log.Fatal(err)
}
fmt.Println(result)
}
Rust:
use std::error::Error;
use std::fmt;
#[derive(Debug)]
struct DivisionError;
impl fmt::Display for DivisionError {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
write!(f, "division by zero")
}
}
impl Error for DivisionError {}
fn divide(a: f64, b: f64) -> Result<f64, DivisionError> {
if b == 0.0 {
Err(DivisionError)
} else {
Ok(a / b)
}
}
fn main() -> Result<(), Box<dyn Error>> {
let result = divide(10.0, 0.0)?;
println!("{}", result);
Ok(())
}
Error Wrapping and Context
Go:
func readConfig(path string) (*Config, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("failed to read config: %w", err)
}
var cfg Config
if err := json.Unmarshal(data, &cfg); err != nil {
return nil, fmt.Errorf("failed to parse config: %w", err)
}
return &cfg, nil
}
Rust:
use std::fs;
use std::path::Path;
use serde::Deserialize;
use thiserror::Error;
#[derive(Debug, Error)]
enum ConfigError {
#[error("failed to read config: {0}")]
ReadFailed(#[from] std::io::Error),
#[error("failed to parse config: {0}")]
ParseFailed(#[from] serde_json::Error),
}
#[derive(Deserialize)]
struct Config {
// fields
}
fn read_config(path: &Path) -> Result<Config, ConfigError> {
let data = fs::read_to_string(path)?;
let cfg: Config = serde_json::from_str(&data)?;
Ok(cfg)
}
Custom Error Types
Go:
type ValidationError struct {
Field string
Message string
}
func (e *ValidationError) Error() string {
return fmt.Sprintf("validation failed for %s: %s", e.Field, e.Message)
}
func validate(user *User) error {
if user.Email == "" {
return &ValidationError{
Field: "email",
Message: "cannot be empty",
}
}
return nil
}
Rust:
use thiserror::Error;
#[derive(Debug, Error)]
#[error("validation failed for {field}: {message}")]
struct ValidationError {
field: String,
message: String,
}
fn validate(user: &User) -> Result<(), ValidationError> {
if user.email.is_empty() {
return Err(ValidationError {
field: "email".to_string(),
message: "cannot be empty".to_string(),
});
}
Ok(())
}
Error Handling Best Practices
| Go Pattern | Rust Pattern | Notes |
|---|---|---|
if err != nil { return nil, err } |
? operator |
Automatic propagation |
fmt.Errorf("context: %w", err) |
map_err(|e| ...) |
Add context |
| Multiple error types | enum with #[from] |
Type-safe error conversion |
errors.Is(err, target) |
match on error type |
Pattern matching |
errors.As(err, &target) |
downcast_ref (rare) |
Usually use enums instead |
| Sentinel errors | Unit variants in enum | enum Error { NotFound } |
Concurrency Patterns
Goroutines → Async Tasks
Philosophy Shift:
- Go: Goroutines are cheap threads, blocking is fine
- Rust: Async tasks are cooperative, blocking requires special handling
Basic Goroutine Translation
Go:
func fetchAll(urls []string) []Result {
results := make(chan Result, len(urls))
for _, url := range urls {
go func(u string) {
results <- fetch(u)
}(url)
}
var out []Result
for i := 0; i < len(urls); i++ {
out = append(out, <-results)
}
return out
}
Rust (with Tokio):
use tokio::task;
async fn fetch_all(urls: Vec<String>) -> Vec<Result> {
let handles: Vec<_> = urls
.into_iter()
.map(|url| task::spawn(async move { fetch(&url).await }))
.collect();
let mut out = Vec::new();
for handle in handles {
if let Ok(result) = handle.await {
out.push(result);
}
}
out
}
// Or using futures::join_all:
use futures::future::join_all;
async fn fetch_all(urls: Vec<String>) -> Vec<Result> {
join_all(urls.iter().map(|url| fetch(url))).await
}
Channels Translation
Go:
func producer(ch chan<- int) {
for i := 0; i < 10; i++ {
ch <- i
}
close(ch)
}
func consumer(ch <-chan int) {
for val := range ch {
fmt.Println(val)
}
}
func main() {
ch := make(chan int, 5)
go producer(ch)
consumer(ch)
}
Rust:
use tokio::sync::mpsc;
async fn producer(tx: mpsc::Sender<i32>) {
for i in 0..10 {
let _ = tx.send(i).await;
}
// Channel closes when tx is dropped
}
async fn consumer(mut rx: mpsc::Receiver<i32>) {
while let Some(val) = rx.recv().await {
println!("{}", val);
}
}
#[tokio::main]
async fn main() {
let (tx, rx) = mpsc::channel(5);
tokio::spawn(async move {
producer(tx).await;
});
consumer(rx).await;
}
Select Statement Translation
Go:
func waitForFirst(ch1, ch2 <-chan string) string {
select {
case msg := <-ch1:
return msg
case msg := <-ch2:
return msg
case <-time.After(1 * time.Second):
return "timeout"
}
}
Rust:
use tokio::sync::mpsc;
use tokio::time::{sleep, Duration};
async fn wait_for_first(
mut rx1: mpsc::Receiver<String>,
mut rx2: mpsc::Receiver<String>,
) -> String {
tokio::select! {
Some(msg) = rx1.recv() => msg,
Some(msg) = rx2.recv() => msg,
_ = sleep(Duration::from_secs(1)) => "timeout".to_string(),
}
}
Worker Pool Pattern
Go:
func workerPool(jobs <-chan Job, results chan<- Result, numWorkers int) {
var wg sync.WaitGroup
for i := 0; i < numWorkers; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for job := range jobs {
results <- process(job)
}
}()
}
wg.Wait()
close(results)
}
Rust:
use tokio::sync::mpsc;
use tokio::task;
async fn worker_pool(
mut jobs: mpsc::Receiver<Job>,
results: mpsc::Sender<Result>,
num_workers: usize,
) {
let mut handles = vec![];
for _ in 0..num_workers {
let mut jobs = jobs.clone();
let results = results.clone();
let handle = task::spawn(async move {
while let Some(job) = jobs.recv().await {
let result = process(job).await;
let _ = results.send(result).await;
}
});
handles.push(handle);
}
drop(jobs); // Close the receiver
for handle in handles {
let _ = handle.await;
}
// results sender dropped, channel closes
}
Sync Primitives Translation
| Go | Rust | Notes |
|---|---|---|
sync.Mutex |
std::sync::Mutex |
Blocking mutex |
sync.RWMutex |
std::sync::RwLock |
Reader-writer lock |
sync.WaitGroup |
Manual with channels or JoinHandle |
No direct equivalent |
sync.Once |
std::sync::Once |
One-time initialization |
sync.Cond |
std::sync::Condvar |
Condition variable |
atomic.* |
std::sync::atomic::* |
Atomic operations |
chan T |
tokio::sync::mpsc |
Async channels |
chan T (sync) |
std::sync::mpsc |
Blocking channels |
Async Best Practices
- Don't block in async: Use
tokio::task::spawn_blockingfor CPU-bound work - Prefer bounded channels: Prevent unbounded memory growth
- Use
tokio::select!carefully: Branches are not evaluated in order - Handle task panics: Use
JoinHandle::awaitand check results - Use
Arcfor shared state: Wrap withMutexorRwLockfor mutation
Memory & Ownership
Garbage Collection → Ownership System
Philosophy Shift:
- Go: GC handles memory, share references freely
- Rust: Ownership rules enforced at compile time, explicit sharing
Ownership Rules in Rust
- Each value has exactly one owner
- When the owner goes out of scope, the value is dropped
- Values can be borrowed (referenced) immutably or mutably
- Only one mutable borrow OR multiple immutable borrows at a time
Basic Ownership Translation
Go:
func process() {
data := []int{1, 2, 3, 4, 5}
// Pass by reference (slice is a reference type)
modifyData(data)
// data is modified
fmt.Println(data)
}
func modifyData(d []int) {
d[0] = 99
}
Rust:
fn process() {
let mut data = vec![1, 2, 3, 4, 5];
// Pass mutable borrow
modify_data(&mut data);
// data is modified
println!("{:?}", data);
}
fn modify_data(d: &mut Vec<i32>) {
d[0] = 99;
}
Shared Ownership (Reference Counting)
Go:
type Cache struct {
data map[string]*Value
}
func (c *Cache) Get(key string) *Value {
return c.data[key] // Returns pointer, GC handles lifetime
}
func main() {
cache := &Cache{data: make(map[string]*Value)}
val := cache.Get("key")
// val can outlive cache in Go (GC prevents dangling pointers)
}
Rust:
use std::sync::Arc;
use std::collections::HashMap;
struct Cache {
data: HashMap<String, Arc<Value>>,
}
impl Cache {
fn get(&self, key: &str) -> Option<Arc<Value>> {
self.data.get(key).cloned() // Clone the Arc, not the Value
}
}
fn main() {
let cache = Cache { data: HashMap::new() };
let val = cache.get("key");
// val holds a reference count to Value
}
Move Semantics
Go:
func transfer() {
data := []int{1, 2, 3}
// Both variables point to same backing array
newData := data
// Both can be used
fmt.Println(data)
fmt.Println(newData)
}
Rust:
fn transfer() {
let data = vec![1, 2, 3];
// Ownership moved to new_data
let new_data = data;
// Error: data is no longer valid
// println!("{:?}", data); // Compile error!
println!("{:?}", new_data); // OK
}
// To keep using data, clone it:
fn transfer_with_clone() {
let data = vec![1, 2, 3];
let new_data = data.clone(); // Explicit copy
println!("{:?}", data); // OK
println!("{:?}", new_data); // OK
}
Borrowing Rules
Go:
func example() {
data := []int{1, 2, 3}
// Can pass to multiple functions
read1(data)
read2(data)
modify(data)
}
func read1(d []int) { /* read only */ }
func read2(d []int) { /* read only */ }
func modify(d []int) { d[0] = 99 }
Rust:
fn example() {
let mut data = vec![1, 2, 3];
// Multiple immutable borrows OK
read1(&data);
read2(&data);
// Mutable borrow (must be exclusive)
modify(&mut data);
// Cannot have immutable and mutable borrows simultaneously:
// read1(&data);
// modify(&mut data); // Compile error!
}
fn read1(d: &Vec<i32>) { /* read only */ }
fn read2(d: &Vec<i32>) { /* read only */ }
fn modify(d: &mut Vec<i32>) { d[0] = 99; }
Lifetime Annotations
Go:
type Parser struct {
source string
}
func (p *Parser) NextToken() string {
// Returns slice of source string
// GC ensures source outlives the token
return p.source[0:5]
}
Rust:
struct Parser<'a> {
source: &'a str,
}
impl<'a> Parser<'a> {
fn next_token(&self) -> &'a str {
// Lifetime 'a ensures returned slice
// doesn't outlive source
&self.source[0..5]
}
}
Interior Mutability
Go:
type Counter struct {
mu sync.Mutex
count int
}
func (c *Counter) Increment() {
c.mu.Lock()
defer c.mu.Unlock()
c.count++
}
func (c *Counter) Value() int {
c.mu.Lock()
defer c.mu.Unlock()
return c.count
}
Rust:
use std::sync::Mutex;
struct Counter {
count: Mutex<i32>,
}
impl Counter {
fn increment(&self) {
let mut count = self.count.lock().unwrap();
*count += 1;
}
fn value(&self) -> i32 {
*self.count.lock().unwrap()
}
}
Memory Ownership Decision Tree
Does data need to be shared across threads?
├─ YES → Arc<T> (thread-safe reference counting)
│ ├─ Needs mutation? → Arc<Mutex<T>> or Arc<RwLock<T>>
│ └─ Read-only? → Arc<T>
└─ NO → Single-threaded sharing
├─ Needs mutation? → Rc<RefCell<T>>
├─ Read-only? → Rc<T>
└─ Exclusive ownership? → Box<T> or owned value
Common Pitfalls
1. Trying to Return Borrowed References Without Lifetimes
Problem:
// Go allows this easily:
// func getData() *Data {
// d := Data{}
// return &d // GC keeps this alive
// }
// Rust compile error:
fn get_data() -> &Data {
let d = Data::new();
&d // Error: returns reference to local variable
}
Solution:
// Return owned value instead:
fn get_data() -> Data {
Data::new()
}
// Or use Box for heap allocation:
fn get_data() -> Box<Data> {
Box::new(Data::new())
}
2. Fighting the Borrow Checker with Cloning
Problem:
// Cloning everything to avoid borrow checker errors
fn process(data: Vec<Item>) -> Vec<Result> {
data.clone() // Unnecessary clone
.iter()
.map(|item| expensive_operation(item.clone())) // Unnecessary clone
.collect()
}
Solution:
// Use borrows instead:
fn process(data: &[Item]) -> Vec<Result> {
data.iter()
.map(|item| expensive_operation(item))
.collect()
}
3. Blocking in Async Contexts
Problem:
// Go goroutines can block freely:
// go func() {
// result := expensiveComputation() // Blocking is fine
// ch <- result
// }()
// Rust async tasks should not block:
tokio::spawn(async {
let result = expensive_computation(); // Blocks the runtime!
result
});
Solution:
// Use spawn_blocking for CPU-bound work:
tokio::spawn(async {
let result = tokio::task::spawn_blocking(|| {
expensive_computation()
})
.await
.unwrap();
result
});
4. Misusing unwrap() and expect()
Problem:
// Go forces explicit error handling:
// val, err := doSomething()
// if err != nil { ... }
// Rust makes it easy to panic:
fn process() {
let val = do_something().unwrap(); // Panics on error!
}
Solution:
// Propagate errors with ?:
fn process() -> Result<(), Error> {
let val = do_something()?;
Ok(())
}
// Or handle explicitly:
fn process() {
match do_something() {
Ok(val) => { /* use val */ },
Err(e) => { /* handle error */ },
}
}
5. Not Understanding String vs &str
Problem:
// Allocating strings unnecessarily:
fn greet(name: String) -> String {
format!("Hello, {}", name)
}
let greeting = greet("World".to_string()); // Allocation
Solution:
// Use &str for parameters when not consuming:
fn greet(name: &str) -> String {
format!("Hello, {}", name)
}
let greeting = greet("World"); // No unnecessary allocation
6. Mutex Deadlocks
Problem:
// Go: defer mu.Unlock() prevents deadlocks
// Rust: forgetting to drop lock guards
fn update(counter: &Mutex<i32>) {
let mut count = counter.lock().unwrap();
*count += 1;
// count guard still held!
let other = counter.lock().unwrap(); // Deadlock!
}
Solution:
// Drop lock guard explicitly or use scopes:
fn update(counter: &Mutex<i32>) {
{
let mut count = counter.lock().unwrap();
*count += 1;
} // count guard dropped here
let other = counter.lock().unwrap(); // OK
}
7. Ignoring Result/Option Types
Problem:
// Compiler warns but doesn't error:
fn main() {
do_something(); // Warning: unused Result
}
Solution:
// Handle or explicitly ignore:
fn main() {
let _ = do_something(); // Explicitly ignore
// Or handle:
if let Err(e) = do_something() {
eprintln!("Error: {}", e);
}
}
8. Overusing Arc<Mutex>
Problem:
// Wrapping everything in Arc<Mutex> because it compiles:
struct App {
config: Arc<Mutex<Config>>, // Config never changes!
cache: Arc<Mutex<Cache>>, // Could use RwLock
}
Solution:
// Use appropriate wrapper for the access pattern:
struct App {
config: Arc<Config>, // Read-only, no mutex needed
cache: Arc<RwLock<Cache>>, // Many readers, few writers
counter: Arc<AtomicUsize>, // Atomic operations are faster
}
Tooling
Translation and Analysis
| Tool | Purpose | Notes |
|---|---|---|
c2rust |
C to Rust transpiler | Produces unsafe Rust, needs manual cleanup |
go2rs |
Experimental Go→Rust | Very limited, not production-ready |
| Manual translation | Most reliable | Use this skill as guide |
Rust Ecosystem Equivalents
| Category | Go | Rust | Notes |
|---|---|---|---|
| HTTP Client | net/http |
reqwest |
Async-first |
| HTTP Server | net/http |
axum, actix-web |
Framework-based |
| JSON | encoding/json |
serde_json |
Type-safe serialization |
| CLI Parsing | flag, cobra |
clap |
Derive-based API |
| Logging | log, zap |
tracing, log |
Structured logging |
| Testing | testing |
Built-in cargo test |
Similar experience |
| Benchmarking | testing |
criterion |
Statistical analysis |
| Async Runtime | Built-in goroutines | tokio, async-std |
Explicit runtime |
| Database | database/sql |
sqlx, diesel |
Type-safe queries |
| Error Handling | errors |
thiserror, anyhow |
Rich error types |
| Configuration | viper |
config, figment |
Type-safe configs |
| Templates | text/template |
tera, handlebars |
Compile-time checks |
| Channels | chan |
tokio::sync::mpsc |
Async-aware |
| Context | context.Context |
Manual (timeout, cancel token) | No built-in equivalent |
| Reflection | reflect |
Limited (Any, procedural macros) |
Prefer static typing |
Development Tools
| Tool | Purpose |
|---|---|
cargo |
Build system and package manager |
rustfmt |
Code formatter (like gofmt) |
clippy |
Linter (more strict than golint) |
rust-analyzer |
LSP server for IDE support |
cargo-watch |
Auto-recompile on file changes |
cargo-expand |
Expand macros for debugging |
miri |
Detect undefined behavior |
Migration Strategy
- Start with types: Translate struct definitions and interfaces to Rust types
- Port tests: Convert test cases to Rust (validates correctness)
- Translate functions: Convert Go functions to Rust, starting with pure functions
- Handle concurrency: Replace goroutines/channels with async/await
- Refine ownership: Optimize borrows and eliminate unnecessary clones
- Benchmark: Compare performance and iterate
Examples
Example 1: Simple - HTTP Client Request
Before (Go):
package main
import (
"encoding/json"
"fmt"
"io"
"net/http"
)
type User struct {
ID int `json:"id"`
Name string `json:"name"`
}
func getUser(id int) (*User, error) {
resp, err := http.Get(fmt.Sprintf("https://api.example.com/users/%d", id))
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("unexpected status: %d", resp.StatusCode)
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
var user User
if err := json.Unmarshal(body, &user); err != nil {
return nil, err
}
return &user, nil
}
After (Rust):
use serde::Deserialize;
use reqwest;
#[derive(Debug, Deserialize)]
struct User {
id: i32,
name: String,
}
async fn get_user(id: i32) -> Result<User, reqwest::Error> {
let url = format!("https://api.example.com/users/{}", id);
let user = reqwest::get(&url)
.await?
.error_for_status()?
.json::<User>()
.await?;
Ok(user)
}
Key Changes:
async/awaitinstead of blocking callsreqwestcrate for ergonomic HTTPserdefor type-safe JSON deserialization?operator for error propagation- No manual body reading (handled by
.json())
Example 2: Medium - Concurrent File Processing
Before (Go):
package main
import (
"fmt"
"io/ioutil"
"os"
"path/filepath"
"sync"
)
type FileResult struct {
Path string
Lines int
Err error
}
func countLines(path string) (int, error) {
data, err := ioutil.ReadFile(path)
if err != nil {
return 0, err
}
lines := 0
for _, b := range data {
if b == '\n' {
lines++
}
}
return lines, nil
}
func processDirectory(dir string) ([]FileResult, error) {
files, err := filepath.Glob(filepath.Join(dir, "*.txt"))
if err != nil {
return nil, err
}
results := make(chan FileResult, len(files))
var wg sync.WaitGroup
for _, file := range files {
wg.Add(1)
go func(path string) {
defer wg.Done()
lines, err := countLines(path)
results <- FileResult{
Path: path,
Lines: lines,
Err: err,
}
}(file)
}
go func() {
wg.Wait()
close(results)
}()
var allResults []FileResult
for result := range results {
allResults = append(allResults, result)
}
return allResults, nil
}
After (Rust):
use std::path::{Path, PathBuf};
use tokio::fs;
use tokio::task;
use glob::glob;
#[derive(Debug)]
struct FileResult {
path: PathBuf,
lines: usize,
error: Option<String>,
}
async fn count_lines(path: &Path) -> Result<usize, std::io::Error> {
let data = fs::read(path).await?;
Ok(data.iter().filter(|&&b| b == b'\n').count())
}
async fn process_directory(dir: &Path) -> Result<Vec<FileResult>, Box<dyn std::error::Error>> {
let pattern = dir.join("*.txt").display().to_string();
let files: Vec<PathBuf> = glob(&pattern)?
.filter_map(Result::ok)
.collect();
let handles: Vec<_> = files
.into_iter()
.map(|path| {
task::spawn(async move {
let lines_result = count_lines(&path).await;
FileResult {
path: path.clone(),
lines: lines_result.as_ref().map(|&l| l).unwrap_or(0),
error: lines_result.err().map(|e| e.to_string()),
}
})
})
.collect();
let mut all_results = Vec::new();
for handle in handles {
if let Ok(result) = handle.await {
all_results.push(result);
}
}
Ok(all_results)
}
// Alternative with futures::join_all:
use futures::future::join_all;
async fn process_directory_v2(dir: &Path) -> Result<Vec<FileResult>, Box<dyn std::error::Error>> {
let pattern = dir.join("*.txt").display().to_string();
let files: Vec<PathBuf> = glob(&pattern)?
.filter_map(Result::ok)
.collect();
let futures = files.into_iter().map(|path| async move {
let lines_result = count_lines(&path).await;
FileResult {
path: path.clone(),
lines: lines_result.as_ref().map(|&l| l).unwrap_or(0),
error: lines_result.err().map(|e| e.to_string()),
}
});
Ok(join_all(futures).await)
}
Key Changes:
tokio::task::spawninstead of goroutines- Async file I/O with
tokio::fs - Iterator methods for concise code
- Type-safe error handling with
Result - No manual channel management (collect into
Vec)
Example 3: Complex - Web Server with Middleware
Before (Go):
package main
import (
"context"
"encoding/json"
"log"
"net/http"
"time"
)
type Server struct {
db Database
cache Cache
}
type Database interface {
GetUser(ctx context.Context, id string) (*User, error)
CreateUser(ctx context.Context, user *User) error
}
type Cache interface {
Get(key string) (interface{}, bool)
Set(key string, value interface{}, ttl time.Duration)
}
type User struct {
ID string `json:"id"`
Email string `json:"email"`
CreatedAt time.Time `json:"created_at"`
}
type CreateUserRequest struct {
Email string `json:"email"`
}
func (s *Server) loggingMiddleware(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
log.Printf("Started %s %s", r.Method, r.URL.Path)
next(w, r)
log.Printf("Completed in %v", time.Since(start))
}
}
func (s *Server) getUser(w http.ResponseWriter, r *http.Request) {
id := r.URL.Query().Get("id")
if id == "" {
http.Error(w, "missing id parameter", http.StatusBadRequest)
return
}
// Check cache first
if cached, ok := s.cache.Get("user:" + id); ok {
user := cached.(*User)
json.NewEncoder(w).Encode(user)
return
}
// Fetch from database
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
defer cancel()
user, err := s.db.GetUser(ctx, id)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
// Cache the result
s.cache.Set("user:"+id, user, 5*time.Minute)
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(user)
}
func (s *Server) createUser(w http.ResponseWriter, r *http.Request) {
var req CreateUserRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
user := &User{
ID: generateID(),
Email: req.Email,
CreatedAt: time.Now(),
}
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
defer cancel()
if err := s.db.CreateUser(ctx, user); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(user)
}
func main() {
server := &Server{
db: &PostgresDB{},
cache: &RedisCache{},
}
http.HandleFunc("/users", server.loggingMiddleware(server.getUser))
http.HandleFunc("/users/create", server.loggingMiddleware(server.createUser))
log.Fatal(http.ListenAndServe(":8080", nil))
}
func generateID() string {
return "user-123" // Simplified
}
After (Rust):
use axum::{
extract::{Query, State},
http::StatusCode,
middleware::{self, Next},
response::{IntoResponse, Response},
routing::{get, post},
Json, Router,
};
use serde::{Deserialize, Serialize};
use std::sync::Arc;
use std::time::{Duration, Instant};
use tokio::time::timeout;
use tracing::{info, instrument};
// Traits (Go interfaces)
#[async_trait::async_trait]
trait Database: Send + Sync {
async fn get_user(&self, id: &str) -> Result<User, DatabaseError>;
async fn create_user(&self, user: &User) -> Result<(), DatabaseError>;
}
#[async_trait::async_trait]
trait Cache: Send + Sync {
async fn get(&self, key: &str) -> Option<User>;
async fn set(&self, key: &str, value: User, ttl: Duration);
}
// Error types
#[derive(Debug)]
enum DatabaseError {
NotFound,
Internal(String),
}
#[derive(Debug, Clone, Serialize, Deserialize)]
struct User {
id: String,
email: String,
created_at: chrono::DateTime<chrono::Utc>,
}
#[derive(Deserialize)]
struct CreateUserRequest {
email: String,
}
#[derive(Deserialize)]
struct GetUserQuery {
id: String,
}
// Shared server state
struct AppState {
db: Arc<dyn Database>,
cache: Arc<dyn Cache>,
}
// Logging middleware
#[instrument(skip(req, next))]
async fn logging_middleware(
req: axum::extract::Request,
next: Next,
) -> Response {
let method = req.method().clone();
let uri = req.uri().clone();
let start = Instant::now();
info!("Started {} {}", method, uri);
let response = next.run(req).await;
info!("Completed in {:?}", start.elapsed());
response
}
// Handlers
#[instrument(skip(state))]
async fn get_user(
Query(params): Query<GetUserQuery>,
State(state): State<Arc<AppState>>,
) -> Result<Json<User>, AppError> {
let cache_key = format!("user:{}", params.id);
// Check cache first
if let Some(user) = state.cache.get(&cache_key).await {
return Ok(Json(user));
}
// Fetch from database with timeout
let user = timeout(
Duration::from_secs(5),
state.db.get_user(¶ms.id)
)
.await
.map_err(|_| AppError::Timeout)?
.map_err(AppError::Database)?;
// Cache the result
state.cache.set(&cache_key, user.clone(), Duration::from_secs(300)).await;
Ok(Json(user))
}
#[instrument(skip(state))]
async fn create_user(
State(state): State<Arc<AppState>>,
Json(req): Json<CreateUserRequest>,
) -> Result<(StatusCode, Json<User>), AppError> {
let user = User {
id: generate_id(),
email: req.email,
created_at: chrono::Utc::now(),
};
// Create user with timeout
timeout(
Duration::from_secs(5),
state.db.create_user(&user)
)
.await
.map_err(|_| AppError::Timeout)?
.map_err(AppError::Database)?;
Ok((StatusCode::CREATED, Json(user)))
}
// Error handling
enum AppError {
Database(DatabaseError),
Timeout,
}
impl IntoResponse for AppError {
fn into_response(self) -> Response {
let (status, message) = match self {
AppError::Database(DatabaseError::NotFound) => {
(StatusCode::NOT_FOUND, "User not found")
}
AppError::Database(DatabaseError::Internal(msg)) => {
(StatusCode::INTERNAL_SERVER_ERROR, "Internal error")
}
AppError::Timeout => {
(StatusCode::REQUEST_TIMEOUT, "Request timeout")
}
};
(status, message).into_response()
}
}
fn generate_id() -> String {
"user-123".to_string() // Simplified
}
#[tokio::main]
async fn main() {
tracing_subscriber::fmt::init();
let state = Arc::new(AppState {
db: Arc::new(PostgresDB::new()),
cache: Arc::new(RedisCache::new()),
});
let app = Router::new()
.route("/users", get(get_user))
.route("/users/create", post(create_user))
.layer(middleware::from_fn(logging_middleware))
.with_state(state);
let listener = tokio::net::TcpListener::bind("0.0.0.0:8080")
.await
.unwrap();
info!("Server listening on {}", listener.local_addr().unwrap());
axum::serve(listener, app).await.unwrap();
}
// Placeholder implementations
struct PostgresDB;
impl PostgresDB {
fn new() -> Self { PostgresDB }
}
#[async_trait::async_trait]
impl Database for PostgresDB {
async fn get_user(&self, id: &str) -> Result<User, DatabaseError> {
// Implementation
todo!()
}
async fn create_user(&self, user: &User) -> Result<(), DatabaseError> {
// Implementation
todo!()
}
}
struct RedisCache;
impl RedisCache {
fn new() -> Self { RedisCache }
}
#[async_trait::async_trait]
impl Cache for RedisCache {
async fn get(&self, key: &str) -> Option<User> {
// Implementation
None
}
async fn set(&self, key: &str, value: User, ttl: Duration) {
// Implementation
}
}
Key Changes:
axumframework for type-safe routing- Middleware as async functions
- Traits with
async_traitfor interfaces - State sharing via
Arc<AppState> - Structured error handling with
IntoResponse tracingfor structured logging- Type-safe extractors (
Query,Json,State) - Explicit timeout handling
- Clone for cache storage (explicit copying)
See Also
For more examples and patterns, see:
meta-convert-dev- Foundational patterns with cross-language exampleslang-go-dev- Go development patternslang-rust-dev- Rust development patternslang-rust-library-dev- Rust library-specific patternslang-rust-memory-eng- Advanced Rust memory management