Right, let’s talk about one of the most quietly contentious design decisions you’ll make in a Go API: how you tell the user, “Hey, something went wrong.” You’ve got two main schools of thought. One is the classic, almost medieval approach of using sentinel errors (ErrSomethingWentWrong). The other is the more modern, pattern-matching-friendly approach of sentinel-free, opaque errors. One isn’t inherently better than the other; they solve different problems. Picking the wrong one for the job is how you build an API that feels like a rusty bear trap for the poor soul trying to use it.

The Old Guard: Sentinel Errors

A sentinel error is a predefined value that you export from your package, against which users can compare their returned error. It’s the Go equivalent of shouting “THE SERVER IS ON FIRE” and having everyone recognize the specific alarm bell.

package mydb

// Let's export our very specific problem.
var ErrConnectionFailed = errors.New("mydb: connection failed")

func Connect() (*Connection, error) {
    // ... attempt connection ...
    if time.Now().Nanosecond()%2 == 0 { // a highly realistic failure condition
        return nil, ErrConnectionFailed
    }
    return &Connection{}, nil
}

And then, your user handles it with a direct comparison:

conn, err := mydb.Connect()
if err == mydb.ErrConnectionFailed {
    // Ah, the connection failed. Let's maybe retry.
    fmt.Println("Retrying...")
} else if err != nil {
    // Some other, unknown error. Panic? Log? Who knows!
    return err
}
// ... use conn ...

Why you’d use this: The beauty here is its brutal simplicity. The user can == check for a specific, known error condition and handle it in a very precise way. It’s perfect for expected, actionable errors. Is the network down? Retry. Is the authentication token invalid? Get a new one. The user knows exactly what to do.

Where it falls apart: The moment you need to wrap that error. fmt.Errorf("could not connect: %w", err) wraps ErrConnectionFailed inside a new error. The user’s err == mydb.ErrConnectionFailed check will now fail because the outer error is a different object. You’ve broken their code. To fix it, they have to use errors.Is(err, mydb.ErrConnectionFailed), which is a bit more verbose but does the digging through the error chain for you. This is the trap many beginners fall into.

The Modernist Approach: Opaque, Sentinel-Free Errors

This philosophy says, “Stop exposing your internals.” Instead of giving users a list of possible errors to compare against, you provide them with ways to query the error for information. The error itself is an opaque object; you can’t see inside it, but you can ask it questions.

This is where Go’s errors and fmt packages really shine. You return wrapped errors, and the user interrogates them with errors.Is and errors.As.

package mydb

// Notice: we don't export any error variables.

func Connect() (*Connection, error) {
    err := attemptConnection()
    if err != nil {
        // Wrap the low-level error with context, but don't expose its type.
        return nil, fmt.Errorf("mydb: connection failed: %w", err)
    }
    return &Connection{}, nil
}

The user’s code now becomes more robust:

conn, err := mydb.Connect()
if errors.Is(err, io.EOF) {
    // Handle a specific underlying cause we know about, perhaps from the net package.
    fmt.Println("Got an EOF, that's odd.")
} else if err != nil {
    // The catch-all. We don't know exactly what went wrong,
    // but we know our connection attempt failed.
    log.Printf("Connection failed: %v", err)
    return err
}

Why this is powerful: You, the API author, have gained the freedom to change your underlying implementation. Maybe tomorrow Connect() fails because of a new TLS handshake issue that returns a crypto/tls error. You don’t have to go define a new sentinel ErrTLSHandshakeFailed and force all your users to update their code. You just wrap the new error type and return it. Code that only checks for a general failure keeps working. Code that wants to be more specific can use errors.As to extract the new *tls.ConnectionStateError and handle it. You’ve created a version-tolerant API.

So, Which One Do I Use?

Use sentinel errors sparingly, only for errors that are truly a part of your API’s contract—errors where you expect the caller to write specific, different logic. sql.ErrNoRows is a famous example. Finding no rows is a distinct, expected outcome from a query, and you often want to handle it differently than a connection error.

For virtually everything else, prefer the opaque, sentinel-free pattern. Wrap your errors with context (fmt.Errorf("descriptive context: %w", err)) and let the caller use errors.Is and errors.As to inspect them. This makes your API more flexible and less brittle. It acknowledges a simple truth: most of the time, the caller doesn’t need to know the exact specific error; they just need to know that the operation you promised them didn’t happen, and maybe get a decent log message out of it. Handling that with a simple if err != nil { return err } is perfectly acceptable and often correct. Don’t feel pressured to create a sentinel for every single thing that can go wrong. You’ll just create noise.