Go for Rust Developers

Similarities and Differences

Go uses garbage collection and explicit error returns for simplicity, while Rust uses compile-time borrow checking and Result types for performance and safety.

The first time you read a Go file

You open a Go source file expecting trait bounds, lifetime annotations, and a Result type bubbling up through a chain of ? operators. Instead you find a function signature that looks almost like C, a block of if err != nil checks that repeats three times, and a go keyword that spawns a concurrent task without a single thread pool declaration. The syntax is familiar. The mental model is not.

Rust and Go share a common goal: build reliable systems software without the chaos of manual memory management. They reach that goal through completely different contracts. Rust proves safety at compile time. Go manages safety at runtime. Rust gives you a roll cage and a preflight checklist. Go gives you a steering wheel and a rearview mirror. Understanding the difference stops being about memorizing syntax and starts being about accepting a different relationship with the machine.

The mental model shift

Rust enforces memory safety through ownership, borrowing, and lifetimes. The compiler acts as a strict auditor. It traces every reference, guarantees that data lives exactly as long as it needs to, and refuses to compile if it cannot prove that two mutable references will never overlap. You pay for that guarantee in compile time and in the cognitive load of satisfying the borrow checker.

Go takes a different path. It uses a concurrent garbage collector to reclaim memory automatically. The compiler does not track lifetimes. It checks types, verifies that you handle explicit error returns, and ensures your code is syntactically valid. The runtime handles the heavy lifting of memory cleanup. You trade compile-time proofs for faster iteration, simpler code, and predictable runtime behavior.

The trade-off is explicit. Rust optimizes for zero-cost abstractions and guaranteed safety. Go optimizes for developer velocity, readability, and predictable performance. Neither approach is universally superior. They solve different problems with different constraints.

Rust pays for safety upfront. Go pays for safety at runtime. Pick the tax you prefer.

A minimal side by side

Here's how you fetch a URL in Go: make the request, check the error, read the body, and return.

package main

import (
	"fmt"
	"io"
	"net/http"
)

// FetchData retrieves the body of a URL as a string.
// It returns an error if the request fails or the body cannot be read.
func FetchData(url string) (string, error) {
	// http.Get returns two values. Go requires you to capture both.
	resp, err := http.Get(url)
	if err != nil {
		// Return early on failure. The error propagates to the caller.
		return "", fmt.Errorf("request failed: %w", err)
	}
	// Defer ensures the body closes when the function returns,
	// preventing connection leaks even if an error occurs later.
	defer resp.Body.Close()

	// Read the entire body into memory. Check the read error explicitly.
	body, err := io.ReadAll(resp.Body)
	if err != nil {
		return "", fmt.Errorf("read failed: %w", err)
	}
	// Convert bytes to string and return success.
	return string(body), nil
}

The function returns two values: the result and an error. Go requires you to capture both. The compiler rejects code that ignores multiple return values unless you deliberately discard one with the blank identifier _. You get assignment mismatch: 1 variable but func returns 2 values if you try to assign the result without acknowledging the error.

Error handling is explicit and repetitive. The pattern if err != nil { return err } appears everywhere. This is verbose by design. The community accepts the boilerplate because it makes the unhappy path visible. You cannot accidentally swallow an error. You cannot forget to check a return value. The verbosity is a feature, not a bug.

Error wrapping uses fmt.Errorf with the %w verb. This preserves the error chain so callers can inspect the root cause using errors.Is or errors.As. There is no Result type. There is no ? operator. Errors are just values that flow through your code like any other data.

Realistic shape of production code

Production code adds context for cancellation and JSON encoding for the response.

package handler

import (
	"context"
	"encoding/json"
	"net/http"
)

// FetchHandler processes an HTTP request and returns JSON data.
// It respects context cancellation and wraps errors for observability.
func FetchHandler(w http.ResponseWriter, r *http.Request) {
	// Context is always the first parameter in Go functions that perform I/O.
	// It carries deadlines, cancellation signals, and request-scoped values.
	ctx := r.Context()

	// Start the request with context awareness.
	// The client will cancel the request if the caller disconnects.
	req, err := http.NewRequestWithContext(ctx, http.MethodGet, "https://api.example.com/data", nil)
	if err != nil {
		http.Error(w, "invalid request", http.StatusBadRequest)
		return
	}

	resp, err := http.DefaultClient.Do(req)
	if err != nil {
		http.Error(w, "service unavailable", http.StatusBadGateway)
		return
	}
	// Defer ensures the response body closes regardless of how the function exits.
	defer resp.Body.Close()

	// Decode the JSON payload. We pass the context to respect deadlines.
	var payload DataResponse
	if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil {
		http.Error(w, "invalid response", http.StatusInternalServerError)
		return
	}

	// Set the content type before writing the response body.
	w.Header().Set("Content-Type", "application/json")
	// Write the JSON response. Errors here indicate client disconnection.
	json.NewEncoder(w).Encode(payload)
}

Notice the conventions. context.Context is the first parameter in functions that do I/O. The convention is to name the variable ctx. Functions that take a context should respect cancellation and deadlines. If the context is cancelled, the function should return early.

If this were a method, the receiver name would be one or two letters matching the type, like (h *Handler) ServeHTTP(...). The receiver name is usually short. You never see (this *Handler) or (self *Handler) in idiomatic Go.

Public names start with a capital letter. Private names start with a lowercase letter. There are no keywords like public or private. Visibility is controlled entirely by capitalization.

Context is plumbing. Run it through every long-lived call site.

Concurrency without the borrow checker

Go concurrency relies on goroutines for lightweight tasks and channels to pass data between them.

// Worker processes a batch of jobs and reports results through a channel.
// It uses a WaitGroup to signal completion to the caller.
func Worker(id int, jobs <-chan int, results chan<- int, wg *sync.WaitGroup) {
	// Defer signals that this worker is done, allowing the main goroutine to proceed.
	defer wg.Done()
	for j := range jobs {
		// Process the job. The channel receives block until a value is available.
		results <- j * 2
	}
}

A goroutine is a lightweight execution context managed by the Go runtime, not the operating system. You can spawn hundreds of thousands of them. The runtime multiplexes them onto a smaller pool of OS threads. Channels provide a type-safe way to pass data between goroutines. The language philosophy favors communicating sequential processes over shared memory.

// Main demonstrates a producer consumer pattern with bounded concurrency.
func Main() {
	// Create buffered channels to decouple producers from consumers.
	jobs := make(chan int, 100)
	results := make(chan int, 100)

	// WaitGroup tracks how many workers are still running.
	var wg sync.WaitGroup

	// Spawn three workers. Each runs concurrently without manual thread management.
	for w := 1; w <= 3; w++ {
		wg.Add(1)
		go Worker(w, jobs, results, &wg)
	}

	// Send ten jobs into the channel. The buffer prevents blocking.
	for j := 1; j <= 10; j++ {
		jobs <- j
	}
	// Close the jobs channel to signal that no more work is coming.
	close(jobs)

	// Wait for all workers to finish before reading results.
	wg.Wait()
	close(results)

	// Collect and print results. The channel closes when all workers exit.
	for r := range results {
		fmt.Println(r)
	}
}

There is no borrow checker verifying that jobs and results are safe to share. The language design assumes that if you pass a channel or a pointer, you are responsible for coordinating access. The runtime does not prevent data races. It detects them in race detection mode and leaves it to you to fix them in production.

Goroutine leaks are the most common runtime bug. A goroutine waits on a channel that never closes. The program hangs. The fix is always the same: ensure every channel has a sender that closes it, or use context cancellation to break the wait.

The worst goroutine bug is the one that never logs.

Pitfalls and compiler behavior

Rust developers often hit specific friction points when reading Go. The first is error handling. Go does not have exceptions. It does not have a Result type. It returns errors as explicit values. The compiler rejects code that ignores multiple return values unless you use the blank identifier. You get assignment mismatch: 1 variable but func returns 2 values if you try to discard the error without acknowledging it.

The second is the lack of a borrow checker. Pointers in Go are simple. They point to memory. The garbage collector reclaims it. You can create data races if you share mutable state across goroutines without synchronization. The compiler will not stop you. You must run the program with the race detector enabled to catch it. The error message looks like WARNING: DATA RACE followed by a stack trace. It is a runtime warning, not a compile error. You fix it by adding mutexes or switching to channels.

The third is tooling expectations. Go has a single formatter. gofmt runs on save in almost every editor. It decides indentation, brace placement, and import grouping. You do not argue about style. You run the tool and move on. The compiler also enforces unused imports. Forget to use a package and you get imported and not used. Forget to import one and you get undefined: pkg. These are small frictions that disappear after a week of muscle memory.

Don't pass a *string. Strings are already cheap to pass by value. Passing a pointer to a string adds indirection without saving memory.

Interfaces are accepted, structs are returned. "Accept interfaces, return structs" is the most common Go style mantra. Function parameters should be interfaces to allow flexibility. Return values should be concrete structs to keep implementation details hidden.

Trust gofmt. Argue logic, not formatting.

Picking the right tool for the job

Use Go when you need to ship a network service, CLI tool, or backend system quickly and want predictable performance without manual memory management. Use Go when your team values readable code, fast compile times, and explicit error handling over compile-time proofs. Use Go when you want to scale concurrency with goroutines and channels without configuring thread pools or async runtimes. Use Rust when you are building systems where memory layout, zero-cost abstractions, and guaranteed data race freedom are mandatory. Use Rust when you are writing embedded software, operating system components, or performance-critical libraries where GC pauses are unacceptable. Use Rust when your domain requires complex domain modeling with strict invariants that the type system can enforce.

Pick Go for developer velocity and operational simplicity. Reach for Rust when the cost of a runtime failure outweighs the cost of compile-time complexity.

Where to go next