1. It's easy to ignore returned errors without any compiler warnings. You have to rely on third party tools such as golangci-lint to report missing error handling.
2. Errors don't carry stack traces with them, you have to rely on third party libraries or custom errors to get that functionality and you will only get it for your own code, not in other libraries you are using.
3. It's unclear who should add context to error messages is it the caller or callee? Usually it gets skipped, leading to useless error messages.
4. Errors are untyped. If you want to decide based on error types, you have to use errors.Is or errors.As, which, surprise, is roughly as expensive computationally as panic-recover. (Source: I did a performance tests on this with Go 1.18) Go might as well add a simpler way to create exceptions. (I wrote a prototype library to that effect a while ago: https://github.com/APItalist/lang )
5. Error messages are too terse and hard to read when using the recommended semantic of "message (cause(cause(cause)))". I'd rather see stack traces, that's much more useful.
6. Most loggers are globally scoped and cannot be injected into code, leading to an all-or-nothing approach. It is not uncommon that you have 3-4 logging libraries as dependencies, which you need to configure separately (if you even can). Also, good luck securing this mess.
Why is that unclear?
Let's say you are writting a db client package and a service around it.
The package's db.Exec(query) method should return and error that will have an error text received from db if any AND\OR context from the package itself.
Then in your service you add additional context to this error if needed.
Finally you log your typical "failed to write HackerNews comment do db with err: %db_package_context: db_error_text_here%"
>6
Not sure about "most" loggers, but I have no problem with zap. Popular, definetelly can be injected etc.
> Why is that unclear?
The usual advice is to follow what the stdlib does. Let's look at an example. Let's say we close a file and then try to set a deadline on it:
f, _ := os.Create("/tmp/filename")
f.Close()
fmt.Printf("%v", f.SetDeadline(time.Now()))
// output: use of closed file
Okay, so in this case, it's the caller's responsibility to keep track of the filename and add the context of what file was already closed, resulting in that error.However, what about the error for trying to write to a closed file?
_, err := f.Write(nil)
fmt.Printf("%v", err)
// output: write /tmp/filename: file already closed
Oh, I see, it's Write's responsibility to add the context of the filename. Huh.This is a clear example of the problem the parent is talking about. The 'os.File' construct knows the filename. Sometimes it adds that as context to errors, sometimes it doesn't. Sometimes the caller needs to add it in, sometimes the callee has already added it.
> Not sure about "most" loggers, but I have no problem with zap. Popular, definetelly can be injected etc.
That may be so, but a lot of libraries use a logger that isn't zap and isn't injectable, or the library doesn't expose a way to inject a logger even if the logger itself supports it. Plus, if you have 3 dependencies, you'll end up with 4 different logging libraries you need to worry about. In the end, you end up having a mess around logging unless you only rely on your own code and don't use libraries.
(Should have phrased the parent better.)
This is one part I like about Go.
You can’t reasonably handle an error condition on a local basis, that’s why exceptions (especially checked ones) are superior. They do the correct thing — either bubble up if it doesn’t make sense to handle them in place, or have them in as broad of a scope as it makes sense with try-catches. Oh and they store the stacktrace, so when an exception does inevitably happen in your software you will actually have a decent shot of fixing it instead of grepping for that generic error message throughout the program (especially if it’s not even written by you!). I swear people lie to themselves with all those if-errs believing they have properly handled an error condition because it took effort.
Also worth noting that Rust doesn’t require you to use errors either; unused errors are a warning in the compiler unless the return type is a result AND you’re trying to access the valid data. This is a better than Go, but not by much in practice.
The error interface doesn’t bother me too much either. Just use errors.Is/As to determine the type of you’re going to do something special with it. It’s way better than having to create unique error/result types for every function.
Who should add context is definitely a problem, but I’ve settled on “the callee”, but in cases where you’re calling something that doesn’t add context you will need to add it in the immediate caller.
Adding context in Go is significantly easier than in other languages (yes, you can use anyhow in Rust, but it’s not considered good practice to put this in library code), and good context largely obviates the need for stack traces anyway. Context is nicer because it can tell you, for example, which loop iteration you were in when things blew up or what the salient parameter values were—stuff you don’t get from a stack trace. Of course, you have to do a bit of work for this benefit, but fmt.Errorf makes this super easy.
Logging also irks me. You can pass a logger like any other data, but mostly people just use global loggers. I haven’t had the multiple loggers problem, but that’s because library authors in Go idiomatically do not add their own logging. What are the languages that do logging well? I’ve had a horrible time with Python (and I think Java but it’s been 10 years).
Is there a language that has error handling "done well " that you like?
However, I wouldn't pick a language purely based on its error handling capabilities. That's treating everything like a nail just because you have a hammer. I'd pick a language that's suitable for the task at hand. Go is suitable for making small(ish) webservices. Over 10k lines of code it becomes really hard to keep things straight. However, that's more due to its very limited scoping abilities.
As far as Go is concerned, you can make the error handling work. In ContainerSSH, we built our own logging overlay, which you can find here: https://github.com/ContainerSSH/libcontainerssh/tree/main/lo... This companion message library has a custom error structure that carries along an error code, which uniquely allows identifying the cause of the error: https://github.com/ContainerSSH/libcontainerssh/blob/main/me... Errors can be wrapped and we added tools to determine, if a certain error has an ancestor with a specific code, allowing for tailored error handling cases. We also added a tool that gathers the comments from the error code constants and adds them to the documentation: https://github.com/ContainerSSH/libcontainerssh/blob/main/cm...
I hope this helps.
It's pretty much Go with Option/Result that forces you to handle errors:
f := os.create('foo.txt') or { println(err) return }
I understand that maybe the language authors in the early days didn’t want to lock anyone into a strict paradigm for how to deal with errors. Like I’m not thrilled about Java’s approach either, but that can never change. But Go is a very popular and established language now. It’s time to fix the error handling mess. There are so many good examples out there to get inspiration from. F#, Swift and Rust have a perfect error handling mechanism.
let greeting_file_result = File::open("hello.txt");
let greeting_file = match greeting_file_result {
Ok(file) => file,
Err(error) => // handle err
};
It isn't much different from: file, err := os.Open("hello.txt")
if err != nil {
// handle error
}
I've come to appreciate Go's simple solution as you get pretty much 90% of what you want from an operation that might produce an error (either the value or an error), and the flow control aspect of it is more explicit than with exceptions.Maybe I don't get Monads, but it seems pretty much equivalent for the common use case.
Go's encodes instead "you may or may not have a file" and "you may or may not have an error". Not the same thing, and extremely rarely what you want, IME.
Other languages also do a better job of helping you verify that you actually handled both cases too.
By the way I wouldn't say we need Monad here, I'd be happy if Go could at least encode Sum types.
let greeting_file = File::open("hello.txt")?l
vs file, err := os.Open("hello.txt")
if err != nil {
return fmt.errorf("faild to open hello; %w, err)
}
However, I would argue the distinction isn't just cosmetic. The compiler prevents me from not checking the error and blowing up the application with a nil pointer exception.The TFA even capes this pattern:
type Result[T any] struct {
Value T
Error error
}
and I wonder if we will start to see it more now that generics are in Go.Rust's is good but not perfect. I often find myself missing stack traces (there are solutions but they're not easy to use), and you're still constrained to a single type of error per function, which means you see a proliferation of specialized error types that are mutually incompatible and have to be converted back and forth.
This is true, but the ? operator expands into a form that does `.into()` conversions of the error variants. If there's a implementation of `From`/`Into` between the error type you're unwrapping and the error type on the function, it automatically converts. This is aided by the "thiserror" crate which provides a derive macro that can generate these automatically.
I can't help but feel there is an even greater generalization here. The error problem you speak of actually applies to every type. To zoom in on errors alone may be missing the forest for the trees. It seems what is special is the need to handle values returned by a function, which includes, but is not limited to, error values. It is something 100% of Go users will have to do almost every time they call a function. Even those which do not return errors.
> Swift and Rust have a perfect error handling mechanism.
Within their respective languages they may be a good fit, but those languages are producer centric. Go is consumer centric. That leaves an impedance mismatch. I do think there is something better out there for Go, but I'm not sure that is where we are going to find it.
_how_ you deal with that failure is a separate question
but it's critical that every fallible expression explicitly and visibly demonstrates the possibility of failure
this is in no way an "error handling mess" -- on the contrary, it is basically the only way to produce robust and reliable software at scale
In my personal opinion it is just not a good language, and I think many judge it based on some false basis that it is somehow “close to the hardware” because it produces a binary. Like, the amount of time it is put next to Rust when the two have almost nothing in common..
It is very verbose, yet Java is the one that is called that, often by Gophers, which is much more concise. It has terrible expressivity, a managed language which is a perfectly fine design choice, yet seemingly every other language with a GC is somehow living in sin.
And still, it doesn’t fail to show up each day on HN.
Me personally: I appreciate the simplicity of it. It's a great language for working with in a team. I wish it was more functional, and had better ways to handle errors, but the simplicity of it all was a breath of fresh air using it in a working environment.
* Relatively simple syntax.
* "Good enough" expressivity-- nothing that's considered "missing" has been a true blocker for most projects.
* An easily accessible concurrency primitive, with the bonus that the runtime can choose to execute goroutines in parallel (when able)-- this comes with no required function coloring or split in a code base.
* A well opinionated environment packaged with the compiler: default formatter, default method for fetching remote deps, default documentation generator, default race detector, default profiler, default testing system.
* Decent portability-- can cross compile relatively easily from one platform to another, doesn't require a larger runtime pre-installed on the foreign host.
* "Batteries included" standard library.
* Inertia-- enough of an active community to pull what you need from the Internet, whether it's guides or code.
* A "good enough" type system to catch some errors before they become runtime errors.
* A "good enough" abstraction for operating on data with: structs, interfaces, and methods. With composition being preferred over inheritance, and embedding bringing handy sugar.
No language is perfect, everyone has an opinion, but for many people this is "close enough" to what they prefer to work with.
Gophers may just be a bit more vocal about it.
Most people are not going to care enough (or have the time) to enumerate every single difference between go and Java, and why they prefer the trade off go makes. I use Java professionally, and go where I can, and there is a lot I prefer about go (I won’t pretend it’s uniformly better, though.)
And fwiw, me and you have gone back and forth about this question in many threads previously. I think asking it from such a high level is not going to very effectively get into the details that actually matter to people.
Finally, I also think a lot of what makes go preferable is not in the realm of language design (at least not the algebraic type theory kind) or specific features. It’s much squishier than that, and involves feelings about how teams work and what developers do in practice (and why they fail).
This is not true of Go, and we could just as well see just as many D, Java, C#, Haskell, OCaml posts, yet they combined are not as frequent “visitors”.
Sure.
Difference is go does not have the complexity you can find in Java and quite opinionated. So you don't have to spend as much time working with the language inself and can focus on getting the job done.
Go is not as expressive and some other languages and does not have the same abstractions that make other languages more suited to be used while developing comlex software.
Thing is - in many cases you simple do not need any of it, but need a fast verbose language with good tooling.
Call it Java Light or something.
I fail to see why I would go with go over java, besides.. perhaps some CLI app? With Graal even that can be implemented in Java.
By having an overly simplistic language, you end up pushing more complexity onto the programmer and into the code base. There is no free lunch.
I find it much more sane to solve and express code in Java. You get terser, more to the point code that reflects the underlying logic more clearly, compared to having to read many lines or pages to understand what's going on.
Java learned the right lessons and I'm quite excited to see their structured concurrency approach. No need to pass channels and contexts everywhere to manually manage hierarchies from what I gather.
I believe that uncolored async (Erlang) precedes colored async (python)
Correction: c# was the first language with colored aaync, still way after Erlang
I've even got a library for using futures in Go. https://stephenn.com/2022/05/simplifying-go-concurrency-with...
Another point is that they do share similarities, which might we might now just describe as being 'modern': They're generally procedual -- you organize your code into modules (not classes) with structs and functions, they generally like static linking, type inference for greater ergonomics, the compiler includes the build system and a packager manager, there's a good formatter.
The above are points for both rust and go compared to C/C++, Python, Java, etc.
So why do I like go? I think mostly it's that it makes some strong engineering trade-offs, trying to get 80% for 20% of the price. That manifests itself in a number of ways.
It's not the fastest language, but neither is it slow.
I really dislike exceptions because there's no documentation for how a function can fail. For this reason I prefer go style errors, which are an improvement on the C error story. Yes it has warts, but it's 80% good enough.
It's a simple language with batteries included. You can generally follow the direction set and be happy. It leads itself to simple, getting-things-done kind of code, rather than being over-abstracted. Being simple also makes for great compile times.
That I agree with.
But Go is anything but modern on a language front. It shares almost nothing with Rust, which actually has a modern type system (from ML/Haskell).
Even if we disagree about exceptions (I do like them as they do the correct thing most of the time, while they don’t mask errors, but include a proper stacktrace), go’s error handling is just catastrophic, being an update from c which is even worse is not a positive.
I’m not a go developer. How does go document how a function can fail?
A Java developer can use checked exceptions so that some information is in the signature. For unchecked exceptions the documentation must explain.
I guess in Go the type of the error return value provides some information but the rest needs to be filled in by the documentation, just like the Java checked exceptions case.
To me, there were a lot of obvious reasons to choose Go in a corporate environment where my success is graded on my ability to deliver and the quality of what I deliver.
For every popular Google project you read about there are many flops, including ones they appear to develop in spite of.
I think Googles stamp gives it some legitimacy, but I think the much likelier explanation is that the values in Go and its design speak to frustrations a lot of people actually have. This thread is full of people arguing in favor of gos error handling. You can dismiss them all as cranks or sheep if you want, but I think that would be misunderstanding something.
I suspect it's a bit more than the Google stamp of approval - the innate simplicity of the language is attractive, it has almost Python-like simplicity without the performance concerns, and it has a "one true way" approach to formatting that settles any bikeshedding arguments in dev teams.
It's not my personal favourite language - the poor error handling discussed in this thread, the mess of the package management system (I mean, Python has a mess of a package management system, but that's more forgivable in a language from the 1990s, not the 2010s), the lack of decent standard library collections, etc. But I can see the appeal.
Go is a language made by Googlers, so its design helps with Google problems. And many of Google's problems are ones of scale.
* Go is straightforward to read. Any reasonably-competent college graduate should have little trouble understanding it and be able to become productive quickly.
* Go compiles into completely static binaries. You don't have issues like "oops, the build system runs CentOS 7 but we're deploying it to Ubuntu 18.04 and their libc's aren't compatible." With containers, you can copy many Go programs into `FROM scratch` images and they will work fine, greatly reducing the attack surface area.
I used to dislike for Go for similar reasons to you and others, but after using it for a few years at $employer, I've come to appreciate its merits. Sure, it can be a bit annoying to write
if err != nil {
return err
}
again and again at first, but I just type 3yy7jp to yank+paste it as needed. You could also configure some editors to detect when you type `if err` and generate it automatically. It's also not uncommon for editors to fold the lines down into a single line.Errors in golang do not have stack traces, and wrapping errors is just an error prone way of manually (and apparently nonperformant way of) generating stack traces.
I had to add `CGO_ENABLED=0`
* sometimes.
It can also dynamically link stuff on occasions, depending what options you use.
You can write huge code bases with it, the tooling is good.
You can use a moderately skilled work force to achieve good results. When some team members leave, you are not left with some wizardry code base behind.
If you view the language as a safer super shell script, it becomes more obvious.
Go was hyped in the beginning, but I get the impression that now it isn't. The most persistently hyped language here is Python. Fortunately, we see more Elixir, Ruby and Go posts lately.
For me, it's:
1) channels (and goroutines, of course)
2) explicit error handling (panics are actually fatal, in contrast to exceptions which are often even used for flow control)
3) easy (cross-)compilation - just go build
And probably a few more reasons I can't remember at the moment. It's just fun to write Go!
Many people coming into Go as a new language immediately start bickering about how they want their previous language features in Go rather than accept what Go has to offer and at least try to understand it. This is the equivalent of moving to another country and then refusing to integrate but being very vocal about how said country sucks.
I genuinely appreciate Go's error handling because it's clean and on the nose. It's not hidden behind weird syntax/values that you have to unpack. It's right in your face all the time. When you read the code, it reads cleanly and understandably, even for a beginner. They don't have to adapt to some weird combination of failures / unpacking/choosing something different when there is an error; you immediately see that there could be an error.
And regarding stack traces, wrapping errors will provide you with failure locations to the line code. You can have all sorts of nice output for errors you can later parse and identify.
I get that some people go into Go because of a shift in the company and have no choice; I feel you. For me, it was a life changer. I learned to love coding again after 15 years of writing Java Beans, Spring annotations, CreateMyFriggingObjectFactorySingletonBuilderFactoryBuilders.
2005 called, they want their Enterprise Java™ jokes back.
I still know modern Java codebases where long descriptive class names are a must. So sadly, while I understand your sarcasm, it is not the case.
The more generic approach to error handling, using do monads (in Haskell and Scala) require some sort of do-notation (Scala's "for comprehensions") to be convenient. And I think this is a step that most mainstream languages are still too afraid to take. I would personally be glad for mainstream and some sort of monadic comprehension to become a mainstream language feature the same way closures became, but this is far from the reality.
So we are left with special-case solutions for specific problems like error-handling, iteration and nullability. Kotlin made it very easy do deal with nulls without a much ceremony (this is slightly more troublesome in Rust or Scala, for instance), while Rust chose to make error handling easier. Of course, they both repurposed the same operator ("?") for this purpose.
What Kotlin does with nullability and what Rust does with error-handling are both becoming quite palatable for mainstream language developers, but it's quite late to change language which have used exceptions (like Kotlin, Java and Python) or error values (like Go) to use monads right now. Entire APIs are built on the existing (and insufficient) error handling scheme.
For instance, we're using Arrow's Either on most new projects at work, but still have to deal with a lot of existing Java APIs, which are exception-based.
This is not a "problem" as much as a conscious philosophical stance:
Errors don't actually exist, only conditions that you dislike. All the error handling you need is if/else. Everything else is unnecessary emotional baggage on some conditions that should not pollute your language. And even less so, gasp, your types (!).
This is a terrible advice. Wrapping is extremely helpful in providing additional context for the error travelling up the call stack. Without wrapping, one typically ends up with software logging generic errors like "file not found" , which you can't act on because... you don't know where it's coming from. If you skip error wrapping, better be ready to enjoy quality time when production crashes.
Still, this doesn't mean that Go does not have stack traces. It does have stack traces for panics, and you can create stack traces by wrapping errors.
I'm aware that it has stack traces for panics, but those should be rare in practice. Day to day debugging was more tedious in golang.
I've tried to like go's verbose error handling (follow the “happy path”) but the error handling signal to noise ratio is skewed in a way that makes developing in go feel slow and boring.
the "sad path" of error handling is equally as important as the "happy path"
Not referring to you personally, but I've heard that sentiment several times now, and I have not seen anything to back it up (as with several other golang claims).
But the provided example is wrong - it is synchronous, as it awaits the computations to finish; and it is broken, because if either `refresh` call panics the caller will hang indefinitely. So it needs some extra defers and maybe a sync.WaitGroup
Also, example 5 is also somewhat not good, because it uses `if err == context.DeadlineExceeded` where it should've said `errors.Is(err, context.DeadlineExceeded)` as it's a good practice to always assume that exceptions may get wrapped (#4 just mentioned that).
It's definitely the canonical way, but communicating errors via channels feels very.. weird, for the lack of a better word (hence why I don't find it idiomatic).
For #4 wrapping your errors creates pretty and logical error messages for free. It should be done in most cases.
I added .Empty() and .Partial() because if you're returning "string, error" from a function, for example, then "" doesn't cut it for me and instead of checking for "" in the calling function, I can instead check for err.Empty(). This doesn't seem like it's useful, but take that idea and apply it to two additional scenarios: a non-pointer to a struct{} with 10 fields (are they all empty?), and partial return values i.e. the function you called threw a warning and only partially populated the return value. Now the calling function can shift the "is empty" checks to the function that actually constructs the return value (or not.)
Now I can call a function, get my custom error type back, and I can determine if there was an issue and whether or not the value is empty or partial regardless of the type (and its complexity.) This paid me back in dividends the moment I wanted to be able to return a warning and a partial result - so not workflow breaking, but also not everything the caller asked for... it's up to the caller to determine if it has what it needs to continue.
In reality, the only reason why errors in Go work the way they do is that it kept the runtime simpler by offloading checking to the developer. The alternative would've been for Go to support sum types, which would've helped make error handling a lot saner, but that was dismissed because they overlapped a little with structurally-typed interfaces (Go's one really good idea). Oh, and the stupid hack that is 'iota'.
And then Go eventually ended up badly re-inventing most of what exceptions do with errors.Is(), errors.As(), and fmt.Errorf("%w", err).
It's such a hot mess.
it turns out that treating errors the same as normal values makes programs more reliable
lots of people get salty about it, for sure
So no, Go's error handling isn't at all good. 1.13 might've made them less execrable, but it didn't make it good.
> Make sure your logging framework is including stack traces so you can trace the error to its cause.
> For example in a web app you would log the error in the http handler when returning the Internal Server status code.
This is different from how I do it, am I doing anything wrong?
I prefer to make it the bottom layer’s responsibility - so, the first source of the error at the boundary of my application and the library that produces the error, rather than the top level of the http handler.
Go errors infamously don’t include stack traces, so how are you supposed to know where your error originated from if you log it from the top level of the http handler?
All in all, errors-as-values is a calm way to deal with unhappy code paths. A clear renunciation of longjump.
(Golang system-originated panics are excepted from this gloss, but they are defined quite narrowly, and ofc catchable.)
func foo() (err error) {
var x any
if x, err = bar(); err == nil {
err = baz(x)
}
if err == nil {
err = bat()
}
if err != nil {
err = fmt.Errorf("%w doing foo <additional info here>", err)
}
return
}
This feels somewhat cleaner to me, in particular by combining error handling (in this case just a simple wrap) in a single place at the end of the function.it obfuscates the control flow, specifically the value that is actually returned
early returns on errors are good, not bad
edit you want
func foo() error {
x, err := bar()
if err != nil {
return fmt.Errorf("bar: %w", err)
}
if err := baz(x); err != nil {
return fmt.Errorf("baz: %w", err)
}
if err := bat(); err != nil {
return fmt.Errorf("bat: %w", err)
}
return nil
}And I think you're going to have problems with this pattern if you join a team using Go in an organisation. The `if err != nil` pattern is the norm, and everyone's used to it (and the regular cadence of Go code; "do the thing, check the error, do the thing, check the error" is very readable).
func foo() (err error) {
var x any
if x, err = bar(); err != nil {
goto fooError
}
if err = baz(x); err != nil {
goto fooError
}
if err = bat(); err != nil {
goto fooError
}
return
fooError:
return fmt.Errorf("%w doing foo <additional info here>", err)
}
Or: func foo() (err error) {
defer func() {
if err != nil {
err = fmt.Errorf("%w doing foo <additional info here>", err)
}
}()
var x any
if x, err = bar(); err != nil {
return
}
if err = baz(x); err != nil {
return
}
err = bat()
return
}
Seeking out different patterns is obviously most applicable in cases where error handling is actually doing something useful or more complicated than just wrapping the error.(My comment was meant to spur first principles discussion from intellectually curious folks, not "nobody does it that way" or "don't do that" edicts. Much of the argument against adding additional language features for error handling is that many of them aren't any better than what can be accomplished already, using existing syntax but different code style conventions. The goto pattern in particular is found all over the stdlib.)
in generated code, sure -- that's why it exists, to support codegen
it's sometimes abused to manage for loop control flow
but the stdlib is definitely not some platonic ideal -- it's a decade+ old code base which has suffered all of the indignities of organic growth
it's full of bad code and terrible anti-patterns
(good stuff, too!)
add them to flycheck or similar, and go is a fantastic experience.
should they be part of the compiler? maybe. i’m not losing sleep over it.
If fmt.Print doesn't work, you should probably just kill the process.
Though, continuing to spend time generating more output that goes nowhere may not be particularly useful either, depending on what the program does.
Still I think ignoring errors writing to stdout is better as a general default. If nothing else, it’s the most common behavior and is thus more likely to fit the user’s expectations.
This is a good rule for any language, because you always ensure an error is logged once. In Go, you can add additional info from the caller to the Context to log higher level info, e.g. a trace span Id.
- logged (and control flow continues)
- returned (and control flow returns)
- managed (and control flow (probably) continues)
if you log an error, then you should not return it
if you return an error, then you should not log it
etc.