Rust vs Go for a New Backend Service and How to Choose
Go optimizes for a team shipping a service quickly; Rust optimizes for control and correctness. Compare error handling, concurrency, memory, hiring and where each language stops being the easy answer.

The comparison is usually argued with opinions, so this one is measured. The same small service was written in Go 1.25 and in Rust 1.99 with Axum, load tested using one shared client, and compared on throughput, latency, memory, build time and the bugs each language lets you write. Then comes the decision, stated plainly, with the cases where it flips. If you already know Go well, the honest conclusion is that you do not need to change; if you are choosing for a team, read the last two sections first.
The service under test
One endpoint, POST /orders, takes a JSON order, validates that it is not empty, multiplies quantity by price for each line and returns the line count and the total. It is the shape of a great many backend handlers: parse, validate, compute, serialise. The Go version uses only the standard library:
package main
import (
"encoding/json"
"log"
"net/http"
)
type Item struct {
SKU string `json:"sku"`
Qty uint32 `json:"qty"`
PriceCents uint64 `json:"price_cents"`
}
type Order struct {
Items []Item `json:"items"`
}
type Total struct {
Lines int `json:"lines"`
TotalCents uint64 `json:"total_cents"`
}
func orders(w http.ResponseWriter, r *http.Request) {
var o Order
if err := json.NewDecoder(r.Body).Decode(&o); err != nil {
http.Error(w, `{"error":"invalid json"}`, http.StatusBadRequest)
return
}
if len(o.Items) == 0 {
http.Error(w, `{"error":"empty order"}`, http.StatusUnprocessableEntity)
return
}
var total uint64
for _, it := range o.Items {
total += uint64(it.Qty) * it.PriceCents
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(Total{Lines: len(o.Items), TotalCents: total})
}
func main() {
http.HandleFunc("POST /orders", orders)
log.Fatal(http.ListenAndServe("127.0.0.1:38701", nil))
}The Rust version uses Axum 0.8 with serde; Json<Order> does the parsing and the compiler checks the types:
use axum::{Json, Router, http::StatusCode, routing::post};
use serde::{Deserialize, Serialize};
#[derive(Deserialize)]
struct Item {
#[allow(dead_code)]
sku: String,
qty: u32,
price_cents: u64,
}
#[derive(Deserialize)]
struct Order {
items: Vec<Item>,
}
#[derive(Serialize)]
struct Total {
lines: usize,
total_cents: u64,
}
async fn orders(Json(order): Json<Order>) -> Result<Json<Total>, (StatusCode, &'static str)> {
if order.items.is_empty() {
return Err((StatusCode::UNPROCESSABLE_ENTITY, r#"{"error":"empty order"}"#));
}
let total = order.items.iter().map(|i| u64::from(i.qty) * i.price_cents).sum();
Ok(Json(Total { lines: order.items.len(), total_cents: total }))
}
#[tokio::main]
async fn main() {
let app = Router::new().route("/orders", post(orders));
let listener = tokio::net::TcpListener::bind("127.0.0.1:38702").await.unwrap();
axum::serve(listener, app).await.unwrap();
}Notice what each version had to write. The Go code checks the decode error by hand and picks a status code in two places. The Rust code has no decode error branch at all: a body that does not match Order is rejected by the extractor before the handler runs, with a 422 or 400 and a message. A u32 quantity cannot be negative or fractional, and the handler cannot see a missing field, so it needs no check. The Go version gets the same effect from its struct tags, but a missing price_cents becomes a zero without any complaint, which is a bug you find in production. The Rust extractor treats a missing required field as an error.
The load test
The client is a 60-line Go program with 64 concurrent connections, each sending the same order in a loop for 10 seconds after a 3-second warm-up, run against each server in turn on the same machine (an Apple M-series laptop with 10 cores, loopback). The same client generated both loads. Two full runs for each server:
go: requests=832340 errors=0 rps=83234 p50=565us p99=3.15ms max=59.7ms rss=22.0MB
rust: requests=1032327 errors=0 rps=103233 p50=511us p99=2.23ms max=18.5ms rss=6.5MB
go: requests=791805 errors=0 rps=79180 p50=591us p99=3.33ms max=13.7ms rss=22.4MB
rust: requests=1025159 errors=0 rps=102516 p50=510us p99=2.27ms max=33.6ms rss=6.5MBRead the four lines as two pairs. Go served 79,180 to 83,234 requests per second; Rust served 102,516 to 103,233. Rust was ahead by roughly 27%. The median latency was 565 to 591 microseconds against 510 to 511 microseconds. The 99th percentile was 3.15 to 3.33 milliseconds for Go and 2.23 to 2.27 for Rust, about 30% lower. Both runs of each server agreed closely, apart from the maximum latency, which is a single slow request and bounces around.
The memory line is the largest gap: 22 MB resident for Go against 6.5 MB for Rust under the same load. Go's runtime carries a garbage collector, goroutine stacks and its scheduler, and Rust carries only the allocator and the tasks. If you run hundreds of small services, or squeeze many instances onto a node, that ratio is the bill.
What the numbers do not say: the load generator was on the same machine and competed for the same cores, the handler does about a microsecond of real work, and a service that spends its time waiting on a database will show none of this, because the database dominates. A 27% throughput edge does not matter at 500 requests a second.
Builds and binaries
Go 1.25 Rust 1.99
binary size 8.1 MB 1.8 MB
rebuild after touching main 0.22 s 2.0 s
clean build (deps cached) - 11.1 sGo's compiler is built for speed: the one-file rebuild took 0.22 seconds, nearly instant. Rust took 2.0 seconds for an incremental release build of this crate and 11 seconds from scratch with the dependencies already downloaded; the first build of a large project is minutes. That gap is a real tax on a tight edit-and-run loop, partly offset by cargo check, which skips code generation, and by cargo watch. The binaries are the other way round: Rust's was 1.8 MB, Go's 8.1 MB, because Go embeds its runtime. Both are single static files that deploy by copying.
The bugs each language lets you write
Speed is not why teams switch. The difference that matters is which mistakes survive to production. Take the standard Go concurrency bug, four goroutines incrementing a counter in a map:
counts := map[string]int{}
for i := 0; i < 4; i++ {
go func() {
for j := 0; j < 1000; j++ {
counts["hits"]++
}
}()
}Run it plainly and, on this run, Go's runtime caught it. It does not always, because the check only fires when two writes actually collide:
fatal error: concurrent map writesThe runtime has a cheap check for concurrent map writes that kills the program, and only when the writes actually collide. With the race detector (go run -race) you get a report with both stack traces:
==================
WARNING: DATA RACE
Read at 0x00c0001040f0 by goroutine 9:
runtime.mapassign_fast64ptr()
main.main.func1()
main.go:16 +0xa0
Previous write at 0x00c0001040f0 by goroutine 7:
main.main.func1()That is good tooling, but it is a test-time tool: it finds races your tests happen to exercise. The same program in Rust does not compile:
error[E0373]: closure may outlive the current function, but it borrows `counts`, which is owned by the current function
--> src/main.rs:8:36
|
8 | handles.push(thread::spawn(|| {
| ^^ may outlive borrowed value `counts`
9 | for _ in 0..1000 {
10 | *counts.entry("hits").or_insert(0) += 1;
| ------ `counts` is borrowed here
|
note: function requires argument type to outlive `'static`
--> src/main.rs:8:22
|
8 | handles.push(thread::spawn(|| {
| ______________________^
9 | | for _ in 0..1000 {The compiler refuses to let a closure on another thread borrow counts, and the move it suggests would only move the map into one thread. The correct versions are Arc<Mutex<HashMap>> or a channel, each a few more lines, and each impossible to get wrong in the way the Go version is. A data race cannot be written in safe Rust.
The nil pointer is the other classic:
ada
panic: runtime error: invalid memory address or nil pointer dereference
[signal SIGSEGV: segmentation violation code=0x2 addr=0x0 pc=0x104ead4b8]
goroutine 1 [running]:find(2) returns a nil pointer and the program crashes with a segmentation violation signal at run time. The Rust version of find returns Option<User>, and the compiler will not let you read .name until you handle None, as described in Option. Go's tooling and conventions mitigate both bugs; Rust removes them. How much that is worth depends on what a bug costs you: a crash that restarts in a second behind a load balancer is cheap, and a corrupted ledger is not.
What Go does better
- Learning and hiring. A developer can be productive in Go in a week. Rust takes longer, because ownership is a new idea. Rust for Go developers is a bridge, but the first month is slower.
- Concurrency by default. Goroutines and channels make "do these N things at once" a keyword. Async Rust is more powerful and has sharper edges, covered in async Rust and Tokio.
- The standard library.
net/http,encoding/json,database/sqlandtestingship together, and a working server needs no dependencies. In Rust you choose a framework, a runtime and a serialisation crate. - Iteration speed. The 0.22-second rebuild adds up over a week.
- Operational simplicity. The garbage collector is tuned for latency, and for most services you never think about it.
What Rust does better
- Memory and tail latency, as measured, and the predictability of having no garbage collector.
- Correctness by construction: no data races, no nil dereference, exhaustive
matchon enums, and errors in return types. - Reuse: the same crate compiles to a service, a command line tool, a WebAssembly module and a Python extension.
- Fine control when you need it: layout, allocation, SIMD, no runtime.
So which one
Pick Go for a new backend service if the team does not already know Rust, the service is a typical request and database handler, and being in production in weeks matters more than the last quarter of efficiency. Pick Rust if any of these holds: you have a latency or memory budget a garbage collector threatens, the domain logic is complicated enough that a strong type system will save debugging time, a bug is expensive (money, safety, security), or the code must also run somewhere Go cannot go, such as in a browser or inside another program. When neither list clearly wins, the tie goes to what the team already knows. A well-written Go service beats a badly-written Rust one on every axis that matters to a user.
When this comparison does not apply
If the service is mostly waiting on other services, a Python or Node service might be fine, and the choice between Go and Rust is a distraction. If the workload is CPU-bound number crunching, compare Rust with C++ instead, as in Rust versus C++ for systems code. And if you are migrating an existing service, the cost is rewriting and re-learning, not the language's merits; do it service by service, as in migrating a service to Rust incrementally.


