Look, let’s get one thing straight: panic is your program screaming “I CAN’T EVEN” and noping out of existence. It’s the nuclear option. For 99% of the errors your code will encounter, you should be using the polite, dignified method of returning an error value. It’s the difference between a waiter gracefully telling you the kitchen is out of the salmon and the same waiter bursting into flames because you asked for extra lemon.

The core principle is about who is in control: the programmer or the user of your package. An error is a condition your function’s caller is expected to handle. A panic is a condition your own code is not equipped to handle, and it’s an admission that everything has gone so horribly wrong that the only sane response is to shut down the current operation (or the whole program) and start fresh.

Use panic for True Programmer Errors

These are the “should never happen” scenarios. They indicate a fundamental flaw in your logic, not a runtime condition out of your control. The classic example is a switch statement that’s supposed to be exhaustive.

package main

type ServerState int

const (
    StateIdle ServerState = iota
    StateRunning
    StateConnected
)

func (s ServerState) String() string {
    switch s {
    case StateIdle:
        return "Idle"
    case StateRunning:
        return "Running"
    case StateConnected:
        return "Connected"
    default:
        // This should be impossible... unless we add a new state and forget to update this method.
        panic(fmt.Sprintf("unexpected ServerState value: %d", s))
    }
}

Why panic here? Because if we hit the default case, it means we, the programmers, screwed up. We added a new constant but failed to update the String() method. This isn’t something a user of this function can or should recover from; it’s a bug that needs to be fixed in the code. A panic will blow up our tests or our program immediately, pointing a giant, flashing arrow at the problem. Returning an error would be worse—it would silently let the bug propagate as a meaningless string or a logged error, making it infinitely harder to track down.

Use Returned error for Expected Runtime Conditions

This is the bread and butter of error handling in Go. Did a file not exist? Did a network call fail? Did the user provide invalid input? Return an error.

package config

import (
    "encoding/json"
    "os"
)

// LoadConfig returns an error if the file can't be read or is invalid JSON.
func LoadConfig(filename string) (*Config, error) {
    data, err := os.ReadFile(filename)
    if err != nil {
        // The file doesn't exist or we can't read it? That's a runtime condition.
        // The caller can decide how to handle this (e.g., use a default config, retry, fail).
        return nil, fmt.Errorf("cannot read config file %s: %w", filename, err)
    }

    var config Config
    if err := json.Unmarshal(data, &config); err != nil {
        // The file exists but is corrupt? Also a runtime condition.
        return nil, fmt.Errorf("cannot parse config file %s: %w", filename, err)
    }

    if config.Port == 0 {
        // Even invalid data inside the file is a runtime condition to be handled by the caller.
        return nil, errors.New("config file must specify a non-zero 'port'")
    }

    return &config, nil
}

Why return an error here? Because the caller of LoadConfig is in the best position to decide what to do. A CLI tool might want to exit with a helpful message. A long-running server might want to log the error and fall back to default settings. A test might want to assert that an error occurred. You, the library author, are not the boss of the caller’s application. Give them the information and let them decide.

The Blurry Line: Initialization and Dependencies

This is where things get a bit philosophical. What about a function that must succeed for the entire program to be valid? A classic example is setting up a critical resource during application startup.

func main() {
    // If we can't connect to the database at the very start of our app,
    // there's absolutely no point in continuing. This is a legitimate use of panic.
    db, err := connectToDatabase()
    if err != nil {
        panic(fmt.Errorf("fatal: cannot connect to database: %w", err))
    }
    defer db.Close()

    // Now start your HTTP server, which can use returned errors for individual requests.
    router := setupRouter(db)
    log.Fatal(http.ListenAndServe(":8080", router))
}

Here, the panic is acceptable because the program is fundamentally broken without a database connection. It’s not a error a user can handle; the program must terminate. However, note how we still check the error and then panic with a nicely formatted message. We don’t just ignore the error and let the db variable be nil, which would cause a much more cryptic panic later. This is a controlled, informative detonation.

The Pitfall: Panicking in a Library

This is the single biggest mistake Go developers make. Never, ever panic across package boundaries for something that isn’t a true programmer error. You have no idea how the calling code is structured. You might be invoked from an HTTP handler in a large web service; a panic there would take down the entire server process instead of just failing the single problematic request. You’ve taken away the caller’s ability to handle the issue gracefully. It’s the ultimate act of arrogance in library design. Always return errors. Let your callers panic if they decide it’s appropriate for their context.