Highly reliable Go code - Log on error

If you are writing Go, then you spend a lot of time handling errors or ignoring errors. Ignoring errors is a code smell, but it is sometimes the right thing to do. This post describes a function I’ve found useful for handling less-important errors while keeping both the linter and logging systems happy. It’s almost too small for a blog post, but it’s so useful that I wanted to share it.

The Problem

A lot of Go functions return an error value along with a result. If you know that the error should always be nil, you can use Must to crash when the error is not nil. Sometimes, though, ignoring the error is almost completely fine! This can happen, for example, when you call .Close() on a connection — the close might fail or it might succeed, but either way you are done with the connection.

When the linter tells you to pay attention to an unhandled error that you know isn’t important, you’re in a bit of a bind. Ignoring errors is a code smell, and so is disabling the linter. What can you do?

The Evolution of a Solution

An obvious solution is to log the error. This leads to dozens of lines like this throughout your codebase:

if err != nil {
    slog.Error("error closing connection", "error", err)
}

This works but quickly becomes repetitive and clutters your code, and becomes a testability nightmare. Let’s improve this step by step.

Step 1: Basic Implementation

First we make a simple helper function:

func LogOnError(err error) {
    if err != nil {
        slog.Error("unhandled error", "error", err)
    }
}

This initial version saves us from writing the same code over and over again and provides a central point for error handling. This is usually enough, but some APIs return “normal” errors that shouldn’t be logged. We can improve our function to handle these cases.

Step 2: Adding Exception Handling

Sometimes certain errors are expected and shouldn’t be logged. For example, getting io.EOF from a connection isn’t really an error. We can improve our function to handle these cases:

func LogOnError(err error, exceptions ...error) {
    if err == nil {
        return
    }
    for _, exception := range exceptions {
        if errors.Is(err, exception) {
            return
        }
    }
    slog.Error("unhandled error", "error", err)
}

Now we can explicitly specify which errors we expect and want to ignore:

rtx.LogOnError(os.Remove(filename), os.ErrNotExist)

This removes the file if it exists, and doesn’t log anything if it doesn’t. If the remove fails for some other reason, then a log message is produced.

Step 3: Adding Stack Traces

As a final improvement, we can make debugging easier by logging the source of the error:

func LogOnError(err error, exceptions ...error) {
    if err == nil {
        return
    }
    for _, exception := range exceptions {
        if errors.Is(err, exception) {
            return
        }
    }
    // If we get here, we have an unhandled error.
    _, file, line, ok := runtime.Caller(1)
    if ok {
        slog.Error(
            "unhandled error", 
            "error", err.Error(), 
            "file", file, 
            "line", line,
        )
    } else {
        slog.Error(
            "unhandled error (no debug information)", 
            "error", err.Error(),
        )
    }
}

Now we’ll know exactly where each unhandled error originates, making debugging much more straightforward.

Real-World Examples

Here are some common scenarios where LogOnError shines:

  1. HTTP Response Writing
// Encoding JSON - errors here usually mean the client disconnected
rtx.LogOnError(encoder.Encode(response))
  1. File Operations
// Closing files after we're done with them
rtx.LogOnError(file.Close())

// Removing temporary files - don't worry if they're already gone
rtx.LogOnError(os.Remove(tempFile), os.ErrNotExist)
  1. Network Operations
// Closing network connections
rtx.LogOnError(conn.Close())
  1. Database Operations
// Closing database transactions
rtx.LogOnError(tx.Rollback())

Benefits

Using LogOnError provides several advantages:

  1. Cleaner Code: Reduces boilerplate while maintaining proper error handling
  2. Better Observability: All unexpected errors are logged with their location
  3. Centralized Control: Single point to modify error handling behavior
  4. Linter Friendly: Satisfies the linter’s requirements for error handling
  5. Flexible: Easy to customize for different types of errors or logging needs

Use it!

This pattern has become an essential part of my Go toolkit. The beauty of this approach is that it’s simple enough to implement in minutes, yet powerful enough to significantly improve your application’s readability. Handling errors can be like “eating your vegetables” — it’s good for you, but sometimes you don’t want to. This makes that handling much easier.

All code in this post is available with a CC:0 license. You can use it in your own projects and modify it to fit your needs. No credit needed, but I’d love to hear from you if you find patterns like this useful. In my experience, reliable systems come from repeatedly applying small improvements like this until the code is so clear that all problems are obvious.

If you found this helpful, you might also enjoy my post about Must and ValueOrDie for easily handling unrecoverable errors. A common thread between these two is that when the mental “speed bump” of adding an if check for errors is removed, Go code becomes easier to read, understand, and write. It kind of doesn’t matter that the check is trivial, it still takes up brain space, and reclaiming that brain space is a good thing.

This isn’t big and it isn’t rocket science, but it is a small improvement that can make a big difference when used consistently, and it saves you from writing a lot of boilerplate code. Use it in good health, and happy coding!

Contribute to the discussion by replying on BlueSky!