The OP points out the wordyness of Go's error syntax. That's a good point. Rust started with the same problem, and added the "?" syntax, which just does a return with an error value on errors. Most Go error handling is exactly that, written out. Rust lacks a uniform error type. Rust has three main error systems (io::Error, thiserror, and anyhow), which is a pain when you have to pass them upward through a chain of calls.
(There are a number of things which tend to be left out of new languages and are a pain to retrofit, because there will be nearly identical but incompatible versions. Constant types. Boolean types. Error types. Multidimensional array types. Vector and matrix types of size 2, 3, and 4 with their usual operations. If those are not standardized early, programs will spend much time fussing with multiple representations of the same thing. Except for error handling, these issues do not affect web dev much, but they are a huge pain for numerical work, graphics, and modeling, where standard operations are applied to arrays of numbers.)
Go has two main advantages for web services. First, goroutines, as the OP points out. Second, libraries, which the OP doesn't mention much. Go has libraries for most of the things a web service might need, and they are the ones Google uses internally. So they've survived in very heavily used environments. Even the obscure cases are heavily used. This is not true of Rust's crates, which are less mature and often don't have formal QA support.
The minus side of Go is too simplistic GC. When latency spikes hit, there are little options to address them besides painful rewrite.
Rust has practically one error, it's the Error trait. The things you've listed are some common ways to use it, but you're entirely fine with just Box<dyn Error> (which is basically what anyhow::Error is) and similar.
An easy rule before you make a knowledge based choice is Thiserror for libraries, helping you create the standard library error types and Anyhow for applications, easy strings you bubble up.
Or just go with anyhow until you find a need for something else.
* Someone tells me to use enums for errors, in a comment like yours
* I try writing the enums by hand, implementing the error trait
* I realize that in order to use the ? operator I need to implement From on my errors (I’ve read so many comments about how awfully verbose Go errors are, so I assume I’m supposed to use ? in Rust). There are also some other traits IIRC but I’ve forgotten them.
* I realize that this is pretty tedious, manual work, so someone points me to thiserr or similar
* Now I’m debugging macro expansion errors and spending approximately the same amount of time
* I ask around and someone tells me not to bother with thiserr and to just write the boilerplate myself or else to use anyhow or boxed errors everywhere
* I try using boxed errors everywhere, which works, but now I have all of these allocations which feels like I’m doing something that will bite me later. Oh well, but now I need to annotate my errors so I can figure out what is actually happening. I guess I should use anyhow for this?
* Anyhow mostly works but this is approximately as verbose as the Go error handling that I’m told is Very Bad, and when I ask for code review most Rust people are telling me not to use anyhow because errors should be enums, at least in the API surface
I’m sure I’m doing it wrong, but as with many things in Rust, the Right Way is so rarely clear and every other Rust person gives different advice about how to solve my problem and the only thing they seem to agree on is that Rust has an easy solution and that I’m following the wrong advice. (Similarly when I had lifetime problems and half the community told me to just use clone and Rc everywhere until I had performance problems, so instead I just had different static analysis problems).
I don’t love Go’s error handling. It feels like there has to be something better than its runtime-typing. But it largely gets out of the way—creating an error is just implementing the Error method, and if you need a concrete type you use Is/As/AsType. Wrapping is fmt.Errorf. All of this is built into the stdlib and used pretty ubiquitously across the ecosystem—I don’t run into “this dependency uses a different error framework”. Error handling is marginally more verbose than with Rust if you are actually attaching context in both, and neither solves the problem of which call frame attaches the context about specific function parameters (e.g., which level of error context specifies that the function was called with path “/foo/bar.baz”). It’s terrible, but it works—feels like the least bad thing until the Rust community can arrive at some consensus and document it in The Book. Or maybe I just need to try again in the LLM era?
The example on the docs page is quite clear:
https://docs.rs/thiserror/latest/thiserror/#example
Including all kinds of errors: Strings, tagged unions and automatically converting from std::io::Error with added context.
That one page document is the entire documentation for the thiserror crate.
Though go certainly did a much better job than rust on the standard library front.
People always tout this as a huge reason for not wanting a too big std in Rust (or "too useful" either), but IMHO that's just talking about reaching theoretical optimals, while leaving the community for years without good guidance via providing a opinionated practical and pragmatic way of doing things. Which I find to be a very unhelpful stance for a tool such as a programming language.
If a design of some std package didn't pass the test of time, and a new iteration would be beneficial, the language can leave its original API version right there, and evolve with a v2, with an improved and better thought out API after learning from the mistakes of v1.
Prime example: "hey we found that math/rand had some flaws, so here is math/rand/v2". A practical solution, and zero dramas as a result of having rand be part of std.
Having too much external code, like npm or rust crates, seems like a nightmare for me.
pub async fn dataset_stats_handler(
Path(dataset_id): Path<String>,
Query(verbose): Query<bool>,
) -> impl IntoResponse {
...
}
With a route like: .route("/datasets/{dataset_id}/stats", get(dataset_stats_handler))
…the "dataset_id" path variable is parsed straight into the dataset_id arg, and a query string "verbose" is parsed into a boolean. Super convenient compared to Go, and you type validation along with it.Many other things to like: The absence of context.Context, the fact that handlers can just return the response data, etc.
What I don't like: Async.
type DatasetStatsQuery struct {
Verbose bool `form:"verbose"`
}
func DatasetStatsHandler(c *gin.Context) {
datasetID := c.Param("dataset_id")
var query DatasetStatsQuery
if err := c.ShouldBindQuery(&query); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// query.Verbose == bool
}}
This is actually a great example - what happens in that Rust version when the input parsing fails? Go makes it explicit.“Anyhow” just allows you to conveniently say “some Error” if you don’t care to write out an API contract specifying types of errors your function might spit out.
Writing primarily applications, I couldn't tell you what error handling frameworks my dependencies are using: I literally don't know, and haven't needed to know in order to display, fail, or succeed.
EDIT to add: I use anyhow for this, so I should also add "add context to an error when I fall" to the list of things I do.
I am on team Io Error [on std rust]", somewhat arbitrarily. If I call a lib that is on Team Anyhow, or Team Custom Error Enum, I will have to do some (Straightfoward, but a little clumsy) conversions if I want ? to work. This is complicated by being able to impl From<ErrorType1> for ErrorType2 only in one direction if you don't control the other crate. (due to the orphan rule)
EDIT: Which I assume all my dependencies have done, given that anyhow is able to consume all of them.
I specifically called out writing applications as my use case: my only objection to tptacek's note is the somewhat universal "in practice". The burden for designing errors for a library that others will use is higher, but that's far from the default/universal experience.
Many more people are going to consume libraries & not produce any of their own, and I think my experience is representative there.
The entire point in Rust is that you wrap Error impls with other Error impls, or translate one impl into another using a match. I've found this is far more flexible and verifiable than most other languages, because if you craft your error types with enough rigor, you can basically have a complete semantic backtrace without the overhead of a real backtrace.
I use thiserror a lot to help with my impls. Notably, all it does is impl Display and Error. It's not a specific other paradigm because it basically compiles out, it's just a macro.
Anyhow is perhaps the closest one to another paradigm because it allows you to discard typed information in favor of just the string messages, but it still integrates well with Errors (and is one).
Generally speaking there has to be a mechanism for optional handling of return values, in Go you can ignore everything (ew), you can use placeholders `_`, or you can explicitly handle things - my preference.
If you say "Well in C you have to handle the returns - I am not across C enough to comment, but I will ask you - Does C actually force you, or does it allow you to say "ok I will put some variables in to catch the returns, but I will never actually use those variables" - because that's very much the same as Go with the placeholder approach
edit: I am told the following is possible in C
trySomething(); // Assumes that the author of trySomething has not annotated the function as a `nodiscard`
(void)trySomething(); // Casts the return(s) to void, telling the compiler to ignore the non-handling
int dummy = trySomething(); // assign to a variable that's never used again
I welcome correction
I agree with the general sentiment though. Rust also has a lot of crates that are stuck semi-unmaintained at some 0.x version, often with no better alternative.
That is to say 0.x doesn’t necessarily mean unmaintained, it can also mean “I don’t want to have to think about how to version APIs / make guarantees about APIs). Eg reqwest is very widely used and actively maintained yet is still at v0.13.
I think it's less that versioning is claimed to be easier with 0.x versions, and more that some people have got into their heads that 1.0 signals either "permanently stable" or "no new versions for several years" and they don't want to commit to that yet.
I do wish more crates would 1.0 (and then 2.0, etc).
Does any language, except like Java, exist with a standard library comprising matching that?
Also, keep in mind that Tauri itself is 14 crates, where each one shows up in your build tree.
https://github.com/tauri-apps/tauri/blob/dev/Cargo.toml
And Ratatui is 6:
[0] https://docs.python.org/3/library/sqlite3.html
Also argparse for Clap:
And then you have httplib3 followed by httplib4.
In other words: I highly prefer the Rust approach.
It doesn't matter a lot whether I rely on the stdlib or another dependency to me.
It's a dependency after all.
People think just because it's the stdlib it's somehow better quality or better maintained, but these are orthogonal concepts.
In the end it depends solely on resources.
Sure, the stdlib may get more of these, but it may also grow fat and unmaintainable...
Nobody has "solved" it, and I don't think that there will ever be one (never say never, though, right?)
For Go we rely on developers of libraries to adhere to the semver versioning scheme accurately, and we cannot "pin" versions (a personal bugbear of mine)
There is a couple of workarounds - using SHAs not unlike the git commit hash to provide a pseudo version, and, vendoring (which is a cache of known dependencies - which brings with it cache management problems)
I had the misfortune of having to use Python with a virtual env on the weekend - it did not end well, and reminded me why I migrated away from Python.
Look at Perl (cpan) Java (maven, gradle) Ruby (gems) Go (dep, glide, vgo, modules) Rust (cargo) Node (npm, yarn, etc)
OSes too Redhat (yum, rpm, etc) Debian (apt) Ubuntu (snap - god why????)
And so on
What Nix and build tools need to agree on is a specification or protocol for "building a software dependency tree". Like, I should be able to say 'builder = cargo' in a Nix derivation and Cargo should be able to pick up everything it needs from the build environment. Alas, there is simply far too much tied up in nixpkg's stdenv for this to be viable, so we have magic stdenv builder behavior via hooks when a build tool is included in nativeBuildInputs.
There's no real way to do that at a language level - we cannot have "Go has determined the package you are trying to fix has not met the versioning requirements proscribed so you cannot submit the patch to fix it"
What language dependencies do is what OSes would think of as "unofficial versioning" that is, an OS will let you install and run an unofficial version of some lib (we've all been there, right, multiple versions of some core library because one doesn't work with whatever you are trying to install), but they will not manage it at all.
Would it make sense to continue using Go for the frontend and doing only the backend in Rust for your user case?
Ultimately, if you have to ask, the Rust vs. Go consideration boils down almost completely to "do you want a managed runtime or not". A generation of Rust programmers has convinced itself that "managed runtime" is bad, that not having one is an important feature. But that's obviously false: there are more programming domains where you want a managed runtime than ones where you don't.
That's not an argument for defaulting to Go in all those cases! There are plenty of subjective reasons to prefer Rust. I miss `match` when I write Go (I do not miss tokio and async Rust, though). They're both perfectly legitimate choices in virtually any case where you don't have to distort the problem space to fit them in (ie: trying to write a Go LKM would be a weird move).
The Rust vs. Go slapfight is a weird and cringe backwater of our field. Huge portions of the industry are happily building entire systems in Python or Node, and smirking at the weirdos arguing over which statically typed compiled language to use. Python vs. (Rust|Go) is a real question. Rust vs. Go isn't.
You don't need a garbage collector which is perhaps half of the Go Runtime when you're using Rust.
You can also bolt on a few crates and get ~95% of what you'd get from Go's runtime.
Go has the best runtime in the world. I'll give it that.
But this is not the only reason...
If youre not writing the code yourself and vibing away which I think most people generally are despite the disdain around here then why would you not choose the "more performant language" (I know that isnt necessarily reality but it is a common perception).
Go's managed runtime is less valuable when the LLM is perfectly happy to slap a bunch of stuff together for you to and approximate it and doesn't complain at all when writing async rust despite some of the rough edges.
I wish TS had more of a runtime. The only thing I'm jealous of with regards to python is how seamlessly you can do JSON schema enforcement on HTTP endpoints. The Zod hoops are a constant source of irritation that only exists because the TS team is dogmatic.
It is illusions and lies all the way down the instant the compiler finishes its job.
"This is the area where Go genuinely shines, and it’s worth being precise about why"
"the lack of GC pauses is a genuine selling point"
"Humans are genuinely bad at reasoning about memory"
"There are cases where the borrow checker is genuinely too strict"
tbc I don't think the article was fully AI-generated, just AI-assisted. If so, the author did a genuinely good job of it! No one else is commenting on it, so clearly it didn't detract much from the substance. It's just weird that this is becoming increasingly common, and increasingly hard to detect.Take this paragraph as example:
> Go got generics in 1.18, and they’re useful, but the implementation has constraints (no methods with type parameters, GC shape stenciling, occasional surprising performance characteristics). Rust generics monomorphize, each instantiation produces specialized code with zero runtime cost. Combined with traits, this gives you real zero-cost abstractions.
Every sentence says something. Every sentence is important and holds its weight. I would expect that kind of writing from very specialized books or papers, not from a blog post. Also, it makes the post harder (and more boring) to read.
That is a signal that person is lacking purpose in their job or life.
I do have one nitpick though: Stating that data races are "caught at compile time" in Rust feels like it is overstating the case, at least a little. It sounds a bit like its implying Rust can also handle things like mutual lock starvation, or other concurrency issues. When that's simply not the case. I know "data race" is technically a formal term, with a decently narrow scope, yet I still think it could be a bit clearer about it.
Kind of funny when your Rust service runs on Kubernetes.
I will say that many of the issues with Go in the article, especially re: nil handling are increasingly solved by thorough coding reviews with Codex. Better to not have the issue in the first place, sure, but these kinds of security bugs are becoming optional to developers who put in at least as much effort to review and understand code as they put into the initial design and execution.
Language data at https://gertlabs.com/rankings?mode=agentic_coding
The downside is that maybe it should fail sometimes when an idiomatic approach isn’t viable… instead it will implement something stupid that compiles and meets the request.
[1] https://github.com/polynya-dev/pg2iceberg
[2] https://www.polarsignals.com/blog/posts/2024/05/28/mostly-ds...
It feels like yesterday when every single project was moving to Go just because it was the new hype, that was until Rust was born.
We are already seeing projects dumping migration to Rust because the grass is not always greener on the other side.
We will be seeing this again, "Migrating from Rust to XYZ"
cargo audit is not built-in, it is 3rd party. (The comparison table near the top isn't clear about that, and the following text stating more is built-in for Rust than for Go might be confusing. I would suggest adding an asterisk to mark built-ins in that table.)
cargo watch has been in "maintenance mode" for some time. The author of that suggests cargo bacon instead.
This can easily be justified for many usecases, but for your vanilla crud app, do you really need Rust?
Per the article, you are getting 20-50% better more performance with Rust. Not worth it unless your team was already fluent in Rust. Now consider a scenario where your team uses AI exclusively to code, now you are spending more time and tokens waiting around to consume large rust builds. As far as I know this is an inherent property of Rust to have its safety guarantees.
I think Rust makes sense for a lot of cases, but for a small web service, overkill and unnecessary imho. If someone ported their crud app from Go to Rust I would question their priorities.
Again I am speaking more in terms of software engineering economics than anything else. Yes, I know in a perfect world Rust binaries are smaller, performance is better and code more “correct”, but the world is hardly perfect. People have to push code quickly, iterate quickly. Teams have churn, Rust, frankly is alien for many, etc.
A quick measurement on my web browser project with almost 600 dependencies:
- A clean "cargo check" was 31s
- An incremental "cargo check" with a meaningful change was 1.5s
Building is a little slower:
- A clean "cargo build" was 56.01s
- An incremental "cargo build" was 4s
But I find that LLMs are mostly calling "check" on Rust code.
---
That's on an Apple M1 Pro. The latest M4/M5 machines as ~twice as fast.
The worst case that would approach a non-incremental build time would be if you were editing a leaf crate. But in almost all cases the leaf crates are 3rd-party dependencies that you would never edit directly.
A real-world worst case is probably more like ~10-20% of an non-incremental builds.
This is cope.
I do give you that rust is more verbose and thus more token heavy. However that verbosity is meaningful and the LLM would have to spend tokens thinking about the code to understand less verbose languages. So I’d consider that a wash - in some cases it hurts and in some it helps.
Not to mention we haven't even gotten to discussing tests.
FWIW, the compile time test above was done comparing consecutive commits. Which in this case happened to have ~3-4 lines changed.
Can you clarify how you're spending tokens on waiting? My understanding is that the LLM isn't actually necessarily doing anything while a build runs. The whole process end to end may take longer for sure (ignoring things like the compiler catching more errors, that's really hard to factor in) but how does that correlate to more tokens?
This. rust emits more information both in its output and the syntax itself more complicated requires more tokens.
From what I've seen, Rust's strictness is actually a huge win for LLMs, as they get much better feedback on what's wrong with the code. Things like null checking that would be a runtime error in Go are implied by the types / evident in the syntax in Rust.
The big thing though is because builds are slower, you will end up waiting longer as tests are modified, rebuilt and run. This difference piles up fast.
You know, shovels are useful, they are just more useful to the shovel manufacturer than the gold diggers.
But in the end it's a cool tool that made it way easier to dig holes and tend to your garden!
The truth of course is somewhere in the middle.
It's difficult to tell what people mean when they say hype sometimes.
Lmao so not an equivalent then? Standard glibc malloc, which is default in rust, will also similarly degrade albeit for different reasons.
hard_tabs = true