The moment the program stops making sense
You are building a service that reads configuration from a JSON file. The file is missing. You could crash the entire program, or you could tell the caller that the file is gone and let them decide what to do. Now imagine a different scenario. Your code tries to read from a slice at index ten, but the slice only has three elements. The program is in an invalid state. Continuing would produce garbage data or corrupt memory. You need to stop immediately.
Go draws a hard line between these two situations. One uses the error type. The other uses panic. Getting the boundary right separates code that handles failure gracefully from code that crashes unpredictably.
Errors are expected. Panics are not.
An error is a regular Go type. It implements a single method, Error() string, and it travels through your code like any other value. You return it, you check it, you wrap it, and you pass it up the call stack. Errors represent conditions the program anticipated. A missing file, a timeout, a malformed request, a database that is temporarily down. The caller knows these things can happen and writes code to handle them.
A panic is a runtime interrupt. It tears through the call stack, executes every deferred function it encounters, and terminates the program. Panics represent conditions the program should never see. A developer forgot to initialize a required dependency. A loop invariant was violated. A type assertion failed because the data structure is corrupted. When a panic happens, the program is broken. Continuing is usually worse than stopping.
Errors are part of the API. Panics are a broken promise.
A minimal example of the difference
Here is the simplest way to see the boundary in action. One function returns an error for a recoverable condition. The other panics for a logic violation.
// ReadConfig returns the parsed configuration or an error if the file is missing.
func ReadConfig(path string) (map[string]string, error) {
// Open the file and check for OS-level errors
data, err := os.ReadFile(path)
if err != nil {
// Wrap the error so the caller knows which file failed
return nil, fmt.Errorf("read config %s: %w", path, err)
}
// Parse the JSON payload into a map
var cfg map[string]string
if err := json.Unmarshal(data, &cfg); err != nil {
// Preserve the original error chain for debugging
return nil, fmt.Errorf("parse config: %w", err)
}
return cfg, nil
}
// SetIndex panics if the index is out of bounds.
func SetIndex(slice []int, idx int, val int) {
// Guard against invalid indices before touching memory
if idx < 0 || idx >= len(slice) {
// Stop execution because the caller broke the contract
panic(fmt.Sprintf("index %d out of bounds for length %d", idx, len(slice)))
}
slice[idx] = val
}
The first function follows the standard Go pattern. It checks the error, wraps it with context, and returns it. The caller decides whether to retry, log, or fail gracefully. The second function assumes the caller knows the slice length. If the caller passes a bad index, the contract is broken. The function panics because there is no safe way to continue.
How the runtime handles each path
When a function returns an error, control flows back to the caller exactly like a normal return. The stack frame unwinds normally. Garbage collection runs on the usual schedule. Nothing unusual happens. The caller receives the error value and branches accordingly.
When panic is called, the runtime stops normal execution. It begins unwinding the stack frame by frame. As it unwinds, it runs every deferred function in the current goroutine. This is why defer is essential for cleanup. If you open a file or acquire a lock, you defer the close or unlock. The deferred function runs even during a panic. Once the stack is fully unwound, the goroutine terminates. If it is the main goroutine, the entire program exits with a non-zero status and prints the panic value and stack trace.
You can catch a panic using recover(), but only inside a deferred function. recover() stops the unwinding and returns the panic value. Most production code never calls recover(). It is reserved for servers that must stay alive despite a misbehaving handler, or for testing frameworks that want to capture panics. Relying on recover() to handle normal control flow defeats the purpose of the type system and makes debugging a nightmare.
Defer runs on panic. The stack unwinds, but your cleanup still fires.
Realistic example: HTTP handler versus initialization
Production code splits these concerns cleanly. Request handlers deal with expected failures. Startup code deals with fatal misconfigurations.
// HandleUser looks up a user by ID and returns a JSON response.
func HandleUser(w http.ResponseWriter, r *http.Request) {
// Extract the ID from the URL path
id := chi.URLParam(r, "id")
if id == "" {
// Missing parameter is an expected client error
http.Error(w, "missing user id", http.StatusBadRequest)
return
}
// Query the database and handle connection or query failures
user, err := db.GetUser(r.Context(), id)
if err != nil {
// Database errors are recoverable at the request level
http.Error(w, "internal server error", http.StatusInternalServerError)
return
}
// Marshal the response and write it to the client
json.NewEncoder(w).Encode(user)
}
// NewServer creates the HTTP server and panics if required ports are missing.
func NewServer(addr string) *http.Server {
// Validate the address format before binding
if addr == "" {
// Fatal setup error means the program cannot run
panic("server address must not be empty")
}
// Create the server with the configured address
return &http.Server{
Addr: addr,
Handler: nil,
}
}
The handler treats a missing ID and a database failure as normal operations. It returns an HTTP status code and continues serving other requests. The constructor treats a missing address as a developer mistake. The program cannot start without it, so it panics immediately. This keeps fatal setup errors visible during development and prevents the server from running in a broken state.
The community accepts verbose error checking because it makes the unhappy path visible. You will see if err != nil everywhere. That is a feature, not a bug. It forces every caller to acknowledge that failure is possible. When you wrap errors, use %w in fmt.Errorf. It preserves the original error chain so errors.Is and errors.As work correctly downstream.
Pitfalls and what the runtime will tell you
Panicking in library code is a design mistake. If you publish a package that panics on invalid input, you hand the caller a bomb. They cannot recover from it without recover(), which is fragile. Validate inputs and return errors instead. Let the application layer decide when a condition is fatal.
The runtime will catch you if you misuse panic. Passing the wrong type to a function that expects a specific interface triggers panic: interface conversion: main.User is not main.Admin: missing method GetRole. Forgetting to check a slice length triggers panic: runtime error: index out of range [5] with length 3. These messages are loud for a reason. They tell you exactly where the invariant broke.
The compiler will also reject code that tries to use panic as a value. Writing x := panic("test") fails with cannot use panic as value. panic is a built-in function that never returns. It terminates execution. You cannot assign its result to a variable.
Never use panic to skip a loop or jump out of a function. The type system exists for a reason.
When to use panic versus returning an error
Use an error when the condition is expected and the caller can take corrective action. Use an error when you are writing a library function and want to give the application layer control over failure. Use panic when a program invariant is violated and continuing would produce incorrect results. Use panic during application startup when a required dependency is missing or misconfigured. Use log.Fatal when you want to print a message and exit cleanly without a stack trace. Use recover() only in top-level server loops or test runners where you must isolate a failing goroutine from the rest of the process.
Return errors for the caller. Panic for the developer.