Go frente a Rust, fase a fase: lo que hacía con uno y ahora hago con el otro
Durante muchos años mi caja de herramientas tuvo dos compartimentos. En uno estaba Rails, para cualquier aplicación web con formularios, sesiones y una base de datos detrás. En el otro estaba Go, para todo lo demás: la CLI que convierte ficheros, el demonio que escucha en un puerto, el servicio que habla con una centralita, la herramienta que se deja corriendo en una máquina sin pedirle nada. Este blog es el ejemplo más visible de ese segundo compartimento: un binario de Go que sirve Markdown desde memoria, sin base de datos y sin contenedor.
Ese segundo compartimento hoy lo ocupa Rust. No ha sido una conversión ni una epifanía, sino un desplazamiento lento, proyecto a proyecto, y creo que ya llevo el tiempo suficiente en los dos lados como para contarlo sin vender humo ni guerra de hinchadas.
Voy a recorrer las fases por las que pasa cualquier proyecto, poner en cada una a los dos lenguajes resolviendo lo mismo con código corto que compila de verdad, y dar un veredicto que puede ser «empate» o «depende». Los ejemplos están compilados con Go 1.26 y Rust 1.98, que es lo que tengo instalado; las versiones estables mientras escribo son Go 1.27.2 (la 1.27 salió el 19 de agosto) y Rust 1.99.0 (del 1 de octubre). Las cifras que doy están medidas aquí; donde no las tengo, doy el orden de magnitud y por qué.
1. Diseño y modelado
Aquí está la diferencia más profunda, y todo lo demás se deriva de ella.
Go modela con structs, interfaces implícitas y poco más. Un «enum» son constantes con iota, el dato asociado a cada estado vive en el struct, y nada en el compilador relaciona lo uno con lo otro. Modelemos el ciclo de vida de un post de este blog: borrador, programado para una fecha o publicado.
package main
import (
"fmt"
"time"
)
type State int
const (
Draft State = iota
Scheduled
Published
)
// El dato asociado al estado vive fuera, y nada impide un Scheduled
// con PublishAt a nil o un Draft con fecha.
type Post struct {
Slug string
State State
PublishAt *time.Time // nil = "no aplica"
}
func describe(p Post) string {
switch p.State {
case Draft:
return "borrador"
case Scheduled:
if p.PublishAt == nil { // nadie me obliga a comprobarlo
return "programado (¿sin fecha?)"
}
return "programado para " + p.PublishAt.Format("2006-01-02")
case Published:
return "publicado"
}
return "desconocido" // ni un aviso si mañana añado un cuarto estado
}
func main() {
when := time.Date(2026, 10, 22, 9, 0, 0, 0, time.UTC)
posts := []Post{
{Slug: "go-frente-a-rust", State: Scheduled, PublishAt: &when},
{Slug: "sin-fecha", State: Scheduled}, // compila sin quejarse
}
for _, p := range posts {
fmt.Println(p.Slug, "->", describe(p))
}
}
Lo mismo en Rust, con un enum que lleva datos en cada variante y un match que el compilador exige completo:
// Cada variante lleva exactamente lo que necesita.
// "Programado sin fecha" no se puede construir.
#[derive(Debug, Clone, PartialEq, Eq)]
enum State {
Draft,
Scheduled { publish_at: String },
Published,
}
struct Post {
slug: String,
state: State,
}
fn describe(p: &Post) -> String {
// match exhaustivo: si mañana añado una variante, esto no compila.
match &p.state {
State::Draft => "borrador".into(),
State::Scheduled { publish_at } => format!("programado para {publish_at}"),
State::Published => "publicado".into(),
}
}
fn main() {
let posts = [
Post {
slug: "go-frente-a-rust".into(),
state: State::Scheduled {
publish_at: "2026-10-22".into(),
},
},
Post {
slug: "otro".into(),
state: State::Draft,
},
Post {
slug: "viejo".into(),
state: State::Published,
},
];
for p in &posts {
println!("{} -> {}", p.slug, describe(p));
}
}
Fíjate en lo que no está en la versión de Rust: ni rama "desconocido", ni comprobación de nil, ni forma de construir un post programado sin fecha. «Hacer imposibles los estados inválidos» en Rust es una técnica; en Go es una disciplina. Y la disciplina falla los viernes por la tarde. En el código real de este blog el estado es un Draft bool y un Date, y funciona porque el dominio es pequeño y lo escribí yo. Con veinte estados y tres personas, el enum con datos y el match exhaustivo son la diferencia entre un refactor de una tarde y uno de una semana. Lo de nil frente a Option es lo mismo en pequeño: el puntero de Go dice «quizá no hay nada» con el mismo tipo con que dice «aquí hay algo», y Option<T> es otro tipo que el compilador te obliga a abrir.
Las interfaces implícitas, en cambio, las echo de menos. Que un tipo satisfaga io.Writer sin declararlo permite adaptar código ajeno sin tocarlo; en Rust el trait se implementa con un impl explícito, y si el tipo y el trait son de otros, la regla del huérfano te obliga a envolverlo. Rust gana en modelado y Go en encaje.
En genéricos la distancia se ha acortado. Go los incorporó en 1.18 y escribí sobre cuándo usarlos; después llegaron slices, maps y cmp (1.21), los iteradores con range sobre funciones (1.23), los alias genéricos (1.24) y, en la 1.27, métodos con sus propios parámetros de tipo, aunque las interfaces sigan sin poder declararlos. Lo que no ha cambiado es lo de debajo: Rust monomorfiza, una copia especializada por combinación de tipos, y una cadena de iter().filter().map() se compila al bucle que habrías escrito a mano; Go usa GC shapes, una implementación por forma de tipo con indirección, y por eso en su día medí hasta un 20 % de diferencia en un bucle caliente. Rust paga ese rendimiento en tiempo de compilación y tamaño de binario.
Veredicto: Rust. Modela mejor y con el compilador de su lado. Que Go quepa en la cabeza también es una virtud de diseño, pero cuando el dominio se complica se nota que el sistema de tipos no está ahí para ayudarte.
2. Prototipado y velocidad de escritura
Y aquí la tortilla se da la vuelta sin discusión.
El «tiempo hasta el primer binario que funciona» en Go es ridículo: go mod init, un main.go, go run ., y tienes un servidor HTTP con rutas, JSON y logging estructurado sin instalar nada. El lenguaje tiene una forma de hacer cada cosa, el formateador no admite opiniones y a los tres días escribes Go como quien lo lleva escribiendo diez años, porque no hay mucho más que aprender. Para una herramienta que tiene que existir esta tarde, Go gana sin matices.
En Rust el primer binario tarda más, y el motivo tiene nombre: el borrow checker. Esto no compila:
fn main() {
let mut titles = vec![String::from("uno"), String::from("dos")];
let first = &titles[0]; // préstamo inmutable
titles.push("tres".into()); // préstamo mutable mientras el otro vive
println!("{first}");
}
error[E0502]: cannot borrow `titles` as mutable because it is also borrowed as immutable
--> src/main.rs:4:5
|
3 | let first = &titles[0]; // préstamo inmutable
| ------ immutable borrow occurs here
4 | titles.push("tres".into()); // préstamo mutable mientras el otro vive
| ^^^^^^^^^^^^^^^^^^^^^^^^^^ mutable borrow occurs here
5 | println!("{first}");
| ----- immutable borrow later used here
El compilador tiene razón: si el push reubica el vector, first apunta a memoria liberada. En Go este programa es legal porque el recolector de basura mantiene viva la memoria lo que haga falta. En Rust tienes que decidir quién es el dueño de cada cosa, y esa decisión se toma mientras escribes. Los primeros meses el borrow checker te para cada diez líneas; después casi no lo notas, porque escribes directamente lo que acepta. Pero el coste cognitivo de esos meses es real y sería deshonesto minimizarlo.
La muleta universal es .clone(): cuando no sabes cómo prestar, copias, y compila. No es un pecado, muchas veces la copia es barata y la claridad vale más; el problema es cuando tapa un diseño que no has pensado, y eso aparece después, en forma de un Arc<Mutex<Vec<String>>> que nadie recuerda por qué está ahí. Porque en Rust el prototipo y el diseño son la misma fase: eso ralentiza el día uno y acelera el día treinta, cuando el prototipo de Go que «ya funcionaba» empieza a necesitar la estructura que nunca tuvo.
Veredicto: Go, con claridad. Rust cobra por adelantado lo que Go te deja pagar a plazos.
3. Manejo de errores
Los dos tomaron la misma decisión de fondo —los errores son valores, no excepciones— y la ejecutaron de forma distinta.
En Go un error es una interfaz, se devuelve como último valor y se comprueba con el if err != nil que tanta tinta ha hecho correr. Lo que lo hace tolerable es la cadena de envoltorios: fmt.Errorf("cargar %s: %w", slug, err) conserva la causa, y errors.Is y errors.As la recorren. Este blog lo usa en todos sus paquetes: un AppError con código HTTP y un Unwrap(), y un WrapHandler que mira si el error que sube es uno de esos y, si no, lo trata como 500. Con la 1.26 llegó errors.AsType, que elimina la variable auxiliar que había que declarar antes de errors.As:
package main
import (
"errors"
"fmt"
"io/fs"
"os"
)
type NotFound struct{ Slug string }
func (e *NotFound) Error() string { return "post no encontrado: " + e.Slug }
func readPost(dir, slug string) ([]byte, error) {
data, err := os.ReadFile(dir + "/" + slug + ".md")
if errors.Is(err, fs.ErrNotExist) {
return nil, &NotFound{Slug: slug}
}
if err != nil {
return nil, fmt.Errorf("leer post %q: %w", slug, err) // envuelvo con %w
}
return data, nil
}
func load(dir, slug string) error {
_, err := readPost(dir, slug)
if err != nil {
return fmt.Errorf("cargar %s: %w", slug, err) // y vuelvo a envolver
}
return nil
}
func main() {
err := load("/tmp/no-existe", "hola")
// errors.AsType (Go 1.26) sustituye al patrón var target; errors.As(err, &target)
if nf, ok := errors.AsType[*NotFound](err); ok {
fmt.Println("404 ->", nf.Slug)
}
fmt.Println(err) // cargar hola: post no encontrado: hola
}
En Rust el error va dentro del tipo de retorno, Result<T, E>, y el operador ? propaga en un carácter. La comunidad ha convergido en dos crates para dos problemas: thiserror para bibliotecas, que deben exponer errores con tipo, y anyhow para aplicaciones, que solo quieren un error opaco con contexto por capas:
use anyhow::Context;
use std::{fs, io, path::PathBuf};
use thiserror::Error;
// La biblioteca define sus errores con tipo: thiserror genera Display y From.
#[derive(Debug, Error)]
enum LoadError {
#[error("post no encontrado: {0}")]
NotFound(String),
#[error("leer {path}")]
Io {
path: PathBuf,
#[source] // conserva la causa, como Unwrap() en Go
source: io::Error,
},
}
fn read_post(dir: &str, slug: &str) -> Result<String, LoadError> {
let path = PathBuf::from(dir).join(format!("{slug}.md"));
fs::read_to_string(&path).map_err(|e| match e.kind() {
io::ErrorKind::NotFound => LoadError::NotFound(slug.to_owned()),
_ => LoadError::Io { path, source: e },
})
}
// La aplicación usa anyhow: un único tipo de error y contexto por capas.
fn load(dir: &str, slug: &str) -> anyhow::Result<usize> {
let body = read_post(dir, slug).with_context(|| format!("cargar {slug}"))?; // el ? propaga
Ok(body.len())
}
fn main() {
if let Err(err) = load("/tmp/no-existe", "hola") {
// downcast_ref es el equivalente de errors.As
if let Some(LoadError::NotFound(slug)) = err.downcast_ref::<LoadError>() {
println!("404 -> {slug}");
}
println!("{err:#}"); // cargar hola: post no encontrado: hola
}
}
Los dos programas imprimen exactamente lo mismo. La diferencia está en lo que el compilador no te deja hacer: en Go puedes ignorar un error con _ o sin asignarlo siquiera, y es el linter quien avisa; en Rust Result lleva #[must_use] y olvidar una variante en un match sobre el error es un fallo de compilación. El ? hace que el camino feliz se lea de un tirón; el if err != nil hace visible cada punto donde algo puede fallar. Son dos virtudes distintas.
Sobre panic: los dos lo reservan para lo irrecuperable. net/http recupera los panics de un handler para no tirar el servidor, y en este blog la regla es no usarlo fuera de init(). En Rust puedes compilar con panic = "abort" y no hay recuperación posible, que para un demonio bajo systemd es lo que quieres.
Veredicto: Rust, por poco. El ?, los errores con variantes y el must_use ganan a los envoltorios con %w, que son un patrón más que un mecanismo. Pero son los dos sistemas de errores con los que más a gusto he trabajado, y vengo de un lenguaje con excepciones.
4. Concurrencia
Si hay una fase donde cada lenguaje enseña su filosofía entera, es esta.
Go hizo de la concurrencia su argumento de venta: goroutines baratas, canales, select, y una convención —el context.Context como primer parámetro— para propagar cancelación y plazos. Este blog es Go concurrente de manual: un store con sync.RWMutex, un scheduler y un watcher que son goroutines con un contexto, y un mutex de escrituras para las mutaciones de varios pasos. Es fácil de escribir y de leer. También es fácil de escribir mal:
package main
import (
"fmt"
"sync"
)
func main() {
total := 0
var wg sync.WaitGroup
for range 4 {
wg.Go(func() { // Go 1.25
for range 1000 {
total++ // carrera de datos: compila, y a veces hasta sale bien
}
})
}
wg.Wait()
fmt.Println(total) // casi nunca 4000
}
Las tres primeras veces que lo ejecuté imprimió 4000; la cuarta, 2732. Eso es lo peligroso: una carrera de datos es un bug que a veces se ve. La defensa es el race detector, go test -race, que instrumenta el binario y con este programa escupe WARNING: DATA RACE a la primera. Tiene dos letras pequeñas que la documentación no esconde: cuesta entre 5 y 10 veces más memoria y entre 2 y 20 veces más CPU, y solo encuentra carreras en código que se ejecuta. Por eso los tests de concurrencia de este blog van siempre con -race.
Rust no detecta la carrera: la hace imposible de escribir. El equivalente con hilos no compila:
use std::collections::HashMap;
use std::thread;
fn main() {
let mut views: HashMap<&str, u32> = HashMap::new();
thread::scope(|s| {
for _ in 0..4 {
s.spawn(|| {
for _ in 0..1000 {
*views.entry("home").or_insert(0) += 1; // no compila
}
});
}
});
println!("{}", views["home"]);
}
error[E0499]: cannot borrow `views` as mutable more than once at a time
8 | s.spawn(|| {
| - ^^ `views` was mutably borrowed here in the previous iteration of the loop
Los traits Send y Sync marcan qué tipos pueden cruzar hilos y cuáles compartirse entre ellos, el compilador los deduce solo, y para compartir estado mutable tienes que decirlo con todas las letras: Arc<Mutex<T>>. Una carrera de datos en Rust seguro es un error de compilación, no el aviso de un detector que has acordado ejecutar. Para quien ha depurado carreras en producción a las tres de la mañana, no es un detalle académico.
Donde Rust lo pasa peor es en lo asíncrono. Go tiene un único modelo, bloqueante en apariencia y multiplexado por debajo, y una función que hace red se escribe igual que una que no. Rust tiene async/await, dos mundos que no se llaman entre sí sin ceremonia: la «coloración de funciones» del ensayo de Bob Nystrom, que sigue ahí aunque async fn en traits llegara en la 1.75 y los closures asíncronos en la 1.85. Y la biblioteca estándar no trae ejecutor: lo pone un runtime externo, en la práctica Tokio (1.53 mientras escribo), tan de facto que buena parte del ecosistema asume que vives dentro de él.
La cancelación muestra la diferencia de fondo. En Go es una convención: cerrar el canal Done() del contexto y confiar en que cada goroutine lo mire en su select. En Rust, un future que se descarta deja de ejecutarse, y tokio::select! cancela las ramas perdedoras soltándolas. Es elegante y tiene trampa: en un bucle, cada rama tiene que ser «segura ante cancelación», y la documentación de Tokio mantiene la lista de qué lo es (recv, accept, read) y qué no (write_all, read_exact, que pueden perder datos a medias). Para un equivalente explícito al contexto de Go está el CancellationToken de tokio-util.
Veredicto: depende de qué te haya dolido más. Go gana en ergonomía: la concurrencia se escribe sin pensar y sin colores, y para un servicio como este blog es exactamente lo que hace falta. Rust gana en garantías: lo que compila no tiene carreras de datos, y eso vale más cuanto más grande es el sistema y menos controlas quién lo toca.
5. Dependencias y ecosistema
Este blog tiene seis dependencias directas, y cada una hace algo que no quiero reescribir: front matter, Markdown, resaltado de sintaxis, vigilar ficheros y procesar imágenes. El router no está en la lista porque desde Go 1.22 el ServeMux de la biblioteca estándar acepta verbos y comodines, y "GET /post/{slug}" con r.PathValue("slug") es todo lo que necesito. Esa es la fuerza de Go: una biblioteca estándar que resuelve el 80 % de lo que hace un servicio, con las mismas garantías que el lenguaje.
El servidor HTTP mínimo que he usado para las mediciones de este post —una ruta, JSON y logging— tiene cero dependencias externas en Go. El equivalente en Rust declara cuatro y resuelve 62 crates en el Cargo.lock:
[package]
name = "server"
version = "0.1.0"
edition = "2024"
[dependencies]
axum = "0.8"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tokio = { version = "1", features = ["rt-multi-thread", "macros", "net"] }
No es una crítica: la biblioteca estándar de Rust es deliberadamente pequeña y la calidad de serde, Tokio, axum o clap es altísima. Es una diferencia de modelo. En Go la decisión de qué usar para HTTP, JSON o logging ya está tomada; en Rust la tomas tú, y con ella heredas un árbol de dependencias que no has leído y un ecosistema más fragmentado, donde cada elección condiciona las siguientes.
Como herramienta, cargo es mejor que go mod: compila, prueba, documenta, publica, gestiona features y workspaces. Go lo compensa con infraestructura: el proxy de módulos y la base de datos de sumas hacen que un módulo descargado una vez sea el mismo para siempre y para todo el mundo. Y govulncheck tiene una virtud única: solo avisa de las vulnerabilidades en funciones que tu código llega a llamar.
govulncheck ./... # Go: solo lo alcanzable desde tu código
cargo audit # Rust: base de datos RustSec
cargo deny check # licencias, duplicados, orígenes, avisos
En Rust, cargo-audit consulta RustSec, cargo-deny añade políticas de licencias y orígenes, y cargo vet, de Mozilla, permite compartir auditorías de crates entre proyectos, la respuesta más seria que conozco a «he instalado sesenta crates y no he leído ninguna». Hacen falta porque la superficie es mayor; en Go es menor por construcción.
Veredicto: Go. Por la biblioteca estándar, por la decisión ya tomada y por la cadena de suministro más corta. Cargo es la mejor herramienta de las dos, pero la mejor dependencia sigue siendo la que no tienes.
6. Compilación y tooling
Cifras, con su contexto: un portátil con un Ryzen de 20 hilos, los proyectos pequeños de este post y tiempo de pared. No extrapoles a tu monolito.
| Medida | Go 1.26 (net/http + JSON) |
Rust 1.98 (axum + Tokio + serde) |
|---|---|---|
| En frío, debug | 4,1 s (caché de Go vaciada) | 8,1 s (dependencias ya descargadas) |
| En frío, release | — (Go no distingue perfiles) | 8,7 s |
Incremental tras tocar main, debug |
0,24 s | 0,20 s |
Incremental tras tocar main, release |
— | 7,4 s |
Dos matices. El «en frío» de Go incluye compilar net/http y todo lo que arrastra, porque desde la 1.20 la biblioteca estándar no viene precompilada; con la caché caliente, un proyecto nuevo compila en menos de un segundo. Y el perfil release de Rust no es incremental por defecto, por eso un cambio de una línea cuesta 7 segundos en release y 0,2 en debug; duele solo cuando el programa en debug es demasiado lento para probarlo.
En proyectos reales la distancia crece. Go está diseñado para compilar rápido —dependencias explícitas, sin cabeceras, sin plantillas que expandir— y un servicio mediano rara vez pasa de decenas de segundos en frío. Rust paga la monomorfización y LLVM: cada uso de un genérico es código nuevo que optimizar, y un proyecto con doscientas crates se va a minutos. Los incrementales en debug siguen siendo rápidos, y cargo check, que verifica tipos y préstamos sin generar código, es lo que se tiene abierto mientras se escribe.
En herramientas las dos cajas se parecen más de lo que sus comunidades admiten. gofmt y rustfmt cerraron la discusión del formato, el mejor regalo que un lenguaje le puede hacer a un equipo. go vet y clippy son los linters de serie, y clippy es más ambicioso: no solo busca errores, enseña idioma, y sus avisos de «esto se escribe así» son la mejor escuela de Rust que conozco; golangci-lint agrega decenas de linters en Go y es estándar de facto sin ser oficial. gopls y rust-analyzer son los servidores de lenguaje, los dos buenos. Y la 1.26 ha reescrito go fix como colección de «modernizadores» que llevan código viejo a los idiomas nuevos.
En mensajes de error no hay color: el compilador de Rust explica, el de Go informa. Vuelve al E0502 de la fase 2: dónde empezó el préstamo, dónde choca, dónde se usa después, una flecha en cada sitio y un rustc --explain. Un error de tipos en Go es una línea correcta y seca. Rust necesita esos mensajes porque sus errores son más difíciles, pero el resultado es que aprender Rust desde los errores del compilador es posible, y aprender Go desde los suyos, no.
Veredicto: Go en tiempos de compilación, Rust en diagnósticos y linter, empate en formato. Si tu bucle es compilar-probar-compilar, Go te devuelve minutos al día; si es escribir-que-el-compilador-me-corrija, Rust te devuelve bugs.
7. Testing
Los dos traen el testing en la caja. Las diferencias son de énfasis.
Go: go test, ficheros _test.go en el mismo paquete, tests de tabla con t.Run, benchmarks (desde la 1.24 con b.Loop(), que evita que el compilador elimine el cuerpo) y, desde la 1.18, fuzzing nativo con la misma herramienta. Para una función Slugify que reduce un título a minúsculas, dígitos y guiones:
package tabletest
import (
"regexp"
"testing"
)
func TestSlugify(t *testing.T) {
tests := []struct{ in, want string }{
{"Go frente a Rust", "go-frente-a-rust"},
{" Hola, mundo! ", "hola-mundo"},
{"2026-10-22", "2026-10-22"},
}
for _, tc := range tests {
t.Run(tc.in, func(t *testing.T) {
if got := Slugify(tc.in); got != tc.want {
t.Errorf("Slugify(%q) = %q, want %q", tc.in, got, tc.want)
}
})
}
}
var valid = regexp.MustCompile(`^[a-z0-9-]*$`)
func FuzzSlugify(f *testing.F) {
f.Add("Go frente a Rust")
f.Fuzz(func(t *testing.T, s string) {
if got := Slugify(s); !valid.MatchString(got) {
t.Fatalf("slug inválido %q para %q", got, s)
}
})
}
Una confesión útil: la primera versión de mi Slugify usaba unicode.IsDigit, y el fuzzer tardó menos de un segundo en encontrar que "۷", un siete en dígitos arábigo-índicos extendidos, pasaba el filtro y producía un slug inválido. Lo guardó en testdata/fuzz/ como caso de regresión y ahí seguirá. Ese es el argumento del fuzzing integrado: lo usas porque está.
Rust: cargo test, los tests unitarios en un módulo #[cfg(test)] del mismo fichero, los de integración en tests/, y algo que Go no tiene y cambia cómo documentas: los doctests. Cada bloque de código en un comentario de documentación se compila y se ejecuta como test, así que los ejemplos de la documentación no pueden quedarse obsoletos. Para la propiedad que probaba el fuzz, la crate proptest genera entradas y reduce al mínimo las que fallan:
/// Reduce un título a minúsculas ASCII, dígitos y guiones.
///
/// ```
/// assert_eq!(proptest_demo::slugify("Go frente a Rust"), "go-frente-a-rust");
/// ```
pub fn slugify(s: &str) -> String {
let mut out = String::new();
let mut dash = false;
for c in s.to_lowercase().chars() {
if c.is_ascii_alphanumeric() {
out.push(c);
dash = false;
} else if !dash && !out.is_empty() {
out.push('-');
dash = true;
}
}
out.trim_end_matches('-').to_owned()
}
#[cfg(test)]
mod tests {
use super::*;
use proptest::prelude::*;
proptest! {
#[test]
fn solo_caracteres_validos(s in ".*") {
let got = slugify(&s);
prop_assert!(got.chars().all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-'));
}
}
}
Lo que Rust no trae de serie lo pone el ecosistema, con un asterisco cada uno. El fuzzing guiado por cobertura es cargo-fuzz, sobre libFuzzer, y requiere el toolchain nightly por un flag inestable de los sanitizadores. Los benchmarks estadísticos son criterion, magnífico y una dependencia más, porque el #[bench] nativo sigue inestable después de muchos años. Y el detector de carreras no existe porque no hace falta en código seguro; para el unsafe está Miri.
Veredicto: Go, por las pilas incluidas. Fuzzing, benchmarks y detector de carreras en estable y con una sola herramienta es difícil de batir. Rust gana en doctests y en cultura de property testing, y si tus tests son sobre todo de propiedades, el veredicto se te da la vuelta.
8. Rendimiento y memoria
La fase donde más mentiras se cuentan, así que cuidado con lo que afirmo.
Go tiene un recolector de basura concurrente diseñado para latencia. Las pausas de parada total son cortas —las notas de Go 1.8 ya hablaban de «normalmente por debajo de 100 microsegundos»— y, según la guía oficial del GC, no son proporcionales al tamaño del heap sino al número de procesadores. El precio es CPU: duplicar GOGC duplica la memoria que te permites y reduce a la mitad el coste de recolección. Desde la 1.19 existe GOMEMLIMIT, un límite blando que hace que el recolector trabaje más al acercarte a él, y la recomendación oficial es usarlo cuando el programa controla su entorno (un servicio en un contenedor con memoria fija), no en una CLI que corre en la máquina de otro. Y la 1.26 ha activado por defecto el recolector Green Tea, con una reducción de entre el 10 y el 40 % del coste del GC en programas que lo usan intensamente, según sus notas.
Rust no tiene recolector. La memoria se libera cuando su dueño sale de ámbito, de forma determinista, y ese determinismo lo hace apto para donde una pausa de cien microsegundos es inaceptable o donde el runtime de Go no cabe. También significa que nada te salva de un diseño que reserva memoria en un bucle caliente: Rust da control, no velocidad gratis, y he visto Rust lento escrito por gente que creía que el lenguaje lo hacía rápido solo.
¿Cuándo importa? Cuando el trabajo es de CPU puro —parsear, comprimir, cifrar, transformar datos en volumen—, Rust suele ganar, por la monomorfización, el control de la disposición en memoria y no pagar GC ni barreras de escritura. Cuando el trabajo es esperar a la red o al disco, la diferencia se diluye y manda la arquitectura. Este blog sirve sus páginas en cientos de microsegundos desde structs en memoria y consume unas decenas de megas; reescribirlo en Rust lo haría más rápido en una métrica que ningún lector puede percibir.
En perfilado, Go tiene una ventaja de integración: pprof viene en la biblioteca estándar, se expone con un import y un endpoint, y la 1.26 ha puesto el flame graph como vista por defecto de su interfaz web. Rust no tiene runtime que instrumentar, así que usas perf con cargo flamegraph encima y criterion para comparar. Funciona igual de bien y hay que montarlo.
Veredicto: Rust cuando importa; empate cuando no. Y conviene ser honesto sobre en qué grupo está tu problema antes de elegir lenguaje por esto.
9. Despliegue
Donde Go me conquistó hace años y donde Rust ha tenido que convencerme, y lo ha hecho.
El argumento clásico de Go es el binario estático que se copia y funciona. Es cierto con un asterisco que descubrí midiendo para este post: mi servidor HTTP mínimo salía enlazado dinámicamente con libc, porque el paquete net incorpora el resolutor de C cuando cgo está disponible, aunque en ejecución use el suyo. Con CGO_ENABLED=0 vuelve a ser estático. La compilación cruzada sigue siendo magia: dos variables de entorno, sin instalar nada.
# Go: estático y cruzado, sin toolchains adicionales
CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -ldflags="-s -w" -o server ./server
# Rust: estático con musl; a otra arquitectura, con el toolchain en un contenedor
cargo build --release --target x86_64-unknown-linux-musl
cross build --release --target aarch64-unknown-linux-gnu
Rust, por defecto, enlaza dinámicamente con la libc del sistema, como cualquier programa de C. Para el binario portátil está el target musl, que produce un ejecutable estático, y para cruzar de arquitectura está cross, que compila dentro de un contenedor con el toolchain adecuado. Funciona, pero son dos pasos que en Go no existen.
Los tamaños, medidos aquí:
| Binario | Go | Rust |
|---|---|---|
| Hola mundo, por defecto | 2,4 MB | 458 KB (release) |
| Hola mundo, sin símbolos | 1,6 MB (-s -w) |
350 KB (strip = true) |
| Servidor HTTP mínimo, sin símbolos | 6,1 MB | 1,3 MB |
| Servidor, estático | 6,0 MB (CGO_ENABLED=0) |
824 KB (musl, LTO y opt-level = "z") |
Go arrastra siempre su runtime —planificador, recolector, reflexión— y el suelo son un par de megas; Rust empieza en cientos de kilobytes y crece con lo que usas. Para una imagen scratch o distroless los dos sirven igual: un fichero sin sistema operativo debajo, y la diferencia entre 6 MB y 1 MB no cambia nada en la práctica.
Y ahora el asterisco que importa, que es el mío. Este blog procesa imágenes con bimg, un envoltorio de libvips, que es una biblioteca de C. Eso significa cgo, y cgo significa que lo anterior deja de ser verdad: el binario del blog pesa 19 MB, ldd le encuentra diez bibliotecas compartidas, libvips.so.42 entre ellas, y la compilación cruzada falla con build constraints exclude all Go files porque cgo se desactiva al cruzar. Es la razón por la que el blog se compila en el propio servidor con un ExecStartPre de systemd. Sería injusto apuntárselo a Go: una crate de Rust que enlace con libvips tendría el mismo problema. Lo que sí es verdad es que el ecosistema de Rust tiende a reimplementar en Rust puro —image, ravif, resvg— precisamente para no tener esta conversación, y el de Go tiende a envolver la biblioteca de C que ya existe.
Veredicto: Go por la simplicidad del caso por defecto, Rust por el tamaño, y empate con C de por medio. Si dependes de una biblioteca de C, los dos te van a hacer compilar donde despliegas o meterlo en una imagen.
10. Mantenimiento y evolución
Los dos tienen una respuesta seria a «¿compilará esto dentro de cinco años?», y son respuestas distintas.
La de Go es la promesa de compatibilidad de Go 1, vigente desde 2012: los programas escritos para la especificación de Go 1 seguirán compilando y funcionando sin cambios durante la vida de esa especificación. Veintisiete versiones después se cumple con muy pocas excepciones, todas documentadas: seguridad, comportamiento no especificado, literales de struct sin claves. Cuando han necesitado romper algo —las variables de bucle en 1.22, los canales de los temporizadores en 1.23— lo han hecho condicionado a la versión declarada en go.mod, de forma que el código viejo conserva el comportamiento viejo. Es la razón por la que un módulo de 2015 compila hoy con el último toolchain, y también por la que las partes del lenguaje que envejecieron mal siguen ahí.
La de Rust son las ediciones: 2015, 2018, 2021 y 2024, la última estabilizada en la 1.85 y, según su anuncio, la mayor hasta ahora. Cada crate declara la suya, crates de ediciones distintas se enlazan sin problema, y cargo fix --edition migra la mayor parte del código solo. Es un mecanismo más fino: permite cambiar sintaxis y semántica —los unsafe extern, el ámbito de los temporales, la palabra reservada gen, las cadenas de let que solo existen en 2024— sin que nadie reescriba nada que no quiera.
Refactorizar es donde se nota el compilador estricto. Cambiar un tipo en Rust es tirar del hilo: el compilador te lleva de error en error por cada sitio que hay que adaptar, y cuando compila, en general funciona. En Go te acompaña en los tipos pero no en las variantes, los nil ni los errores ignorados; el refactor grande se apoya más en los tests. La contrapartida está en leer código ajeno años después: Go se lee de arriba abajo con pocas sorpresas, porque hay una forma de hacer cada cosa. Rust admite más ingenio —traits sobre traits, macros, lifetimes explícitos— y un código ingenioso de hace tres años escrito por otro puede ser una tarde de arqueología.
Y la incorporación de gente. Un desarrollador competente escribe Go útil en una semana y Go idiomático en un mes; es el argumento de Go que menos tiene que ver con la técnica y más con un equipo. Rust son meses hasta que el borrow checker deja de ser un obstáculo, y durante esos meses rinde poco esa persona y también quien le revisa el código.
Veredicto: empate, con reparto. Go para que cualquiera pueda entrar y leer; Rust para que el cambio grande sea seguro y el lenguaje evolucione sin arrastrar sus errores.
11. FFI y casos frontera
Aquí el desplazamiento de Go a Rust deja de ser cuestión de gusto, y la conexión con Rails es natural.
Para hablar con C, Go tiene cgo, y cgo funciona, como demuestra la libvips de este blog:
package main
/*
#include <stdlib.h>
#include <string.h>
*/
import "C"
import (
"fmt"
"unsafe"
)
func main() {
cs := C.CString("hola desde cgo")
defer C.free(unsafe.Pointer(cs)) // la memoria de C la libero yo
fmt.Println(C.strlen(cs))
}
Pero cada llamada cruza una frontera: las goroutines tienen pilas pequeñas que crecen y C espera una pila de sistema, el recolector no ve lo que C guarda, el planificador tiene que apartar el hilo. Es un cambio de contexto en miniatura, y aunque la 1.26 lo ha abaratado un 30 %, se nota cuando la llamada es pequeña y frecuente. Y rompe el binario estático y la compilación cruzada, como vimos. El dicho de la comunidad, «cgo no es Go», tiene razón en que es una herramienta de último recurso.
Rust no tiene esa frontera porque ya vive al otro lado: sin runtime, con la convención de llamada de C disponible con extern "C" en las dos direcciones y a coste cero. El riesgo se marca con unsafe, y en la edición 2024 los bloques extern se declaran unsafe explícitamente. Por eso Rust está en el kernel de Linux, en el firmware y en las extensiones de otros lenguajes, que es la parte que me interesa.
Porque aquí entra Ruby. En enero de 2022 Maxime Chevalier-Boisvert propuso portar YJIT, el JIT de Shopify, de C a Rust, y Matz lo aceptó en tres días con una frase que define el límite: «it's OK to use Rust to implement YJIT (and other optional features in the future), but mainline CRuby will not be implemented in Rust». Ruby 3.2 llegó con ese YJIT en Rust y un requisito nuevo, rustc 1.58 para compilarlo; ZJIT, en Ruby 4.0, pide ya Rust 1.85. El intérprete que ejecuta mis aplicaciones Rails lleva dentro un compilador escrito en Rust.
Y para una gema con código nativo, la combinación que lo ha hecho fácil es rb-sys, los bindings de bajo nivel, y magnus, la capa de alto nivel. Esto es el ejemplo del README de magnus: una biblioteca dinámica que, al cargarse desde Ruby, define una función global implementada en Rust:
[lib]
crate-type = ["cdylib"]
[dependencies]
magnus = "0.9"
use magnus::{Error, Ruby, function};
fn distance(a: (f64, f64), b: (f64, f64)) -> f64 {
((b.0 - a.0).powi(2) + (b.1 - a.1).powi(2)).sqrt()
}
#[magnus::init]
fn init(ruby: &Ruby) -> Result<(), Error> {
ruby.define_global_function("distance", function!(distance, 2))?;
Ok(())
}
Se empaqueta con la gema rb_sys y rake-compiler, y el resultado es una extensión nativa que se compila al instalar, como las de C de toda la vida, pero con tipos, sin fugas de memoria y con cargo test. Esto con Go no lo haría nunca: tendría que pasar por cgo hacia una capa en C que hablara con la API de C de Ruby, con dos runtimes y dos recolectores en el mismo proceso. El hueco que Go no podía ocupar en mi mundo Rails lo ocupa Rust sin esfuerzo.
En WebAssembly pasa algo parecido. Go compila a wasm desde hace años y tiene el target wasip1 desde la 1.21, pero arrastra el runtime: un hola mundo me sale en 2,5 MB de .wasm. Rust tiene wasm32-unknown-unknown con wasm-bindgen para el navegador y wasm32-wasip1 para fuera de él, y el mismo hola mundo se queda en decenas de kilobytes. La respuesta de Go para esos casos, y para microcontroladores, es TinyGo, un compilador distinto sobre LLVM; la de Rust es el propio lenguaje con no_std.
Veredicto: Rust, sin discusión. Es la fase donde ni siquiera compiten.
12. Cierre: por qué me he ido moviendo
Si has llegado hasta aquí ya sabes que no hay ganador, y que tampoco lo buscaba. Lo que hay es un patrón. Go ganó en las fases de velocidad: prototipar, compilar, desplegar sin pensar, incorporar a alguien. Rust ganó en las de profundidad: modelar, errores, garantías de concurrencia, rendimiento cuando importa y, sobre todo, la frontera con C, con Ruby y con todo lo que no tiene runtime.
| Fase | Go | Rust | Veredicto |
|---|---|---|---|
| Diseño y modelado | Structs, interfaces implícitas, iota |
Enums con datos, match exhaustivo, traits |
Rust |
| Prototipado | go run y a correr |
El borrow checker cobra por adelantado | Go |
| Errores | %w, errors.Is/As/AsType |
Result, ?, thiserror, anyhow |
Rust, por poco |
| Concurrencia | Goroutines, context, race detector |
Send/Sync, Tokio, carreras imposibles |
Depende |
| Dependencias | Stdlib enorme, proxy y sumdb | Cargo excelente, ecosistema fragmentado | Go |
| Compilación y tooling | Segundos; mensajes secos | Minutos en frío; mensajes que enseñan | Reparto |
| Testing | Tabla, fuzz, bench y -race de serie |
Doctests, proptest; fuzz en nightly | Go |
| Rendimiento | GC de baja latencia, GOMEMLIMIT |
Sin GC, determinista | Rust cuando importa |
| Despliegue | Estático y cruzado con dos variables | Pequeño; musl y cross |
Go, salvo con C |
| Mantenimiento | Promesa de Go 1 | Ediciones | Empate |
| FFI y frontera | cgo, con coste | extern "C" a coste cero; magnus, WASM |
Rust |
Lo que hacía con Go eran servicios, demonios y herramientas de línea de comandos, y Go sigue siendo excelente para eso. Lo que he ido necesitando se parece cada vez más a lo otro: código que vive dentro de un proceso de Ruby, binarios minúsculos, piezas donde un bug de concurrencia no es una opción y programas que van a durar y a cambiar de manos. Para eso Rust es mejor, y no me cuesta admitirlo porque no le debo lealtad a ningún lenguaje; un desarrollador de Ruby escribiendo un cliente de Spotify en Rust ya no sorprende a nadie.
Seguiría eligiendo Go para un servicio HTTP que habla JSON y poco más, para una CLI que tiene que existir esta semana, para un equipo donde no todo el mundo va a vivir en el lenguaje, y para cualquier cosa donde el recolector no molesta, que es casi todo. Elegiría Rust cuando el runtime no cabe —una gema, un módulo WASM, un chip—, cuando el trabajo es de CPU, cuando la corrección vale más que la velocidad de escritura y cuando el programa va a durar años en manos que no son las mías.
Queda la pregunta que alguien me va a hacer: ¿reescribiría este blog en Rust? Las piezas existen —axum, un parser de Markdown, un resaltador, un vigilante de ficheros— y saldría un binario más pequeño con las mismas garantías que hoy me dan un mutex y unos tests con -race. Lo que no ganaría es nada que un lector pueda notar, y el problema de libvips sería el mismo. La respuesta honesta es que no lo sé, y que la pregunta importa menos de lo que parece: este blog no tiene base de datos ni contenedor porque elegí la complejidad que podía depurar con cat, y esa regla vale también para el lenguaje. Go era la herramienta justa para este problema cuando lo escribí, y lo sigue siendo. Que para los problemas que vienen la herramienta justa sea otra no dice nada malo de ninguno de los dos.