When is it OK to panic in Go?
Level: Intermediate
If you've been working with Go for a while, you might be familiar with the Go proverb "don't panic".
It's a pithy way of saying: "handle errors gracefully, or return them to the caller to handle gracefully, instead of passing errors to the built-in panic()
function".
And while "don't panic" is a great guideline that you should follow, sometimes it's taken to mean that you should no-way, never, ever call panic()
. And I don't think that's true. The panic()
function is a tool, and there are some rare times when it might be the appropriate tool for the job.
In this post we'll talk through what panic()
does and why it's generally better to avoid using it, discuss some scenarios where panicking can be appropriate, and finish with a few real-world examples.
Panicking vs. returning errors
Let's begin by creating a timeIn()
function, which takes a IANA time zone name and returns the current time in that zone.
In Go, if the timeIn()
function encounters an error, the normal and idiomatic way to deal with it would be to return the error to the caller. Like so:
package main
import (
"fmt"
"os"
"time"
)
func timeIn(zone string) (time.Time, error) {
loc, err := time.LoadLocation(zone)
if err != nil {
return time.Time{}, err // Return any error from time.LoadLocation()
}
return time.Now().In(loc), nil
}
func main() {
tz := "Europe/Wonderland"
t, err := timeIn(tz)
if err != nil {
fmt.Println("Error:", err)
os.Exit(1)
}
fmt.Println("Current time in", tz, "is", t)
}
$ go run main.go
Error: unknown time zone Europe/Wonderland
exit status 1
In theory, you could handle the potential error inside timeIn()
by passing it to panic()
— instead of returning it. Like this:
package main
import (
"fmt"
"time"
)
func timeIn(zone string) time.Time {
loc, err := time.LoadLocation(zone)
if err != nil {
panic(err) // Call panic() with the error as the argument
}
return time.Now().In(loc)
}
func main() {
tz := "Europe/Wonderland"
t := timeIn(tz)
fmt.Println("Current time in", tz, "is", t)
}
$ go run main.go
panic: unknown time zone Europe/Wonderland
goroutine 1 [running]:
main.timeIn({0x4c2c7e?, 0x7d40fe626108?})
/tmp/main.go:11 +0xc5
main.main()
/tmp/main.go:20 +0x2b
exit status 2
When you call panic()
in your Go code, it will do the following four things:
- Immediately stop normal execution of the code in the current function. Nothing after the call to
panic()
will be executed. - Run any deferred functions for the current goroutine in reverse (LIFO) order.
- Print out
panic:
and the value you passed to thepanic()
function toos.Stderr
, along with a stack trace for the current goroutine at the pointpanic()
was called, . - Terminate the program with exit code 2.
Why is panicking considered bad?
The panic()
function itself isn't intrinsically bad. In fact, what it does for you is really quite nice — the running of deferred functions, the printing of the stack trace... this is good stuff.
It's more that returning errors is normally better.
When you call panic()
in a function, it always sets off the same fixed chain of events that we described above.
Whereas if the function returns the error, the caller has full control over how that error is managed. It could be logged, presented to a user, the function could be retried, or the error could even be ignored. Alternatively, the error could be propagated again back up the call stack to the grandparent caller to manage. It all depends on the use case. When you return an error, the caller has control and flexibility to handle it in the most appropriate way.
There are also some other benefits of returning errors:
When propagating errors back up the call stack, you can optionally wrap them to provide additional context at each step. This extra context can make errors more informative and useful, and potentially make debugging easier than relying solely on the stack trace from a
panic()
.It's easier to write unit tests for a function when it returns errors. It's certainly not impossible to verify that a function panics when you expect it to during a test, but it is more awkward and less clear than just checking an error return value.
If you're creating a package for other people to import and use, it's polite to return errors instead of panicking. Remember: a panic will terminate the running application, which people using your package may not expect or appreciate! It's better to return an error, and leave it up to the caller to decide what to do next. They can always call
panic()
with the error if they want.Finally, it's just the Go way. Errors are normally returned — it's what the Go standard library mostly does, and it's what other Gophers have come to expect as standard. By sticking with this convention, your code is more predictable and easier for other people to follow.
So, returning errors (or handling them gracefully then-and-there) is almost always better. Which leaves us with the question, when is panicking the better option?
When is panicking appropriate?
To answer this, it's helpful to distinguish between what I'll call "operational errors" and "programmer errors" for the purpose of this post.
By operational errors, we're talking about errors that you might reasonably expect to happen during the operation of your program. Some examples are errors caused by a database or network resource being temporarily unavailable, the permissions on a file being wrong, a timeout on a long-running operation, or invalid user input. These errors don't necessarily mean there is a problem with your program itself — in fact they're often caused by things outside the control of your program.
Operational errors are to be expected. And because you know there's a chance they'll occur during normal operation, you should endeavour to return them to the caller and gracefully handle them in a way that makes the most sense for your program. Don't use panic()
to manage them.
By programmer errors, we're talking about errors which should "never" happen during the operation of your program — the kind of error that stems from a developer mistake, a logical flaw in your codebase, or trying to use another piece of code in an unsupported way. Ideally, you'd spot programmer errors during development or testing, rather than having them surface in production. And (hopefully!) they should be relatively rare.
When you encounter a programmer error, it means that your program finds itself in an unexpected state. And in this scenario, calling panic()
is much more commonly accepted as an appropriate thing to do.
For all the good reasons that we talked about above, if it is possible to safely and gracefully manage the error by returning it up the call stack, then you should default to doing that still.
But using panic()
can be a good and appropriate choice when either:
- The error is truly unrecoverable (that is, there is no reasonable way to safely continue operating and handle the error more gracefully); or
- Returning the error would add an unacceptable amount of complexity or additional error handling code to the rest of your codebase — all for something that you never expect to see in production.
You can see this logic play out in some of the Go standard library operations that trigger a panic. For instance:
- Dividing an integer or float by 0
- Accessing an out-of-bounds index in slice or array
- Dereferencing a nil pointer
- Trying to use a nil map
- Unlocking a mutex that isn't locked
- Sending on a closed channel
- Defining two flags with the same name in the same
flag.FlagSet
- Passing an integer
< 100
or> 999
tohttp.ResponseWriter.WriteHeader()
- When a
sync.WaitGroup
counter drops below zero
What do all these have in common?
First, they're programmer errors. If any of these things happen, it's due to a logical mistake in your codebase or you trying to use a language feature or function in an unsupported way. These things shouldn't happen during normal operation in production.
And if they returned an error, it would add an arguably unacceptable amount of extra error handling to everyone's Go code. Just imagine if you had to check for an error return value every time you use the /
operator, access a value in a slice, or unlock a mutex. It would add a lot of overhead.
So, in summary, it can be appropriate to use panic()
to deal with programmer errors that are either unrecoverable or where returning an error would add an unacceptable amount of extra error handling to the rest of your codebase.
Exactly what constitutes "an unacceptable amount" is your judgement call, based on your experience and particular codebase. And that's OK. There's no exact right or wrong answer here.
On top of this, there are a couple of other scenarios where I think calling panic()
can be appropriate:
In a last-ditch 'guard clause' to prevent a particular operation happening when it shouldn't. If the panic ever gets executed, it indicates a bug in your program or violation of some internal business logic.
When you don't want the program to continue and there are no better options for dealing with the error beyond calling
panic()
.
Real-world examples and discussion
By now I hope it's clear that panic()
should be used sparingly and only when it really makes sense. Personally, probably about half of the codebases I work on don't call panic()
at all, and even when they do, it's only in a few places.
So with that said, here are a few real-life examples from recent codebases I've worked on.
Example one
Here's an example from a web application, where we have some code to retrieve a user value from the HTTP request context.
type contextKey string
const userContextKey = contextKey("user")
func contextGetUser(r *http.Request) user.User {
user, ok := r.Context().Value(userContextKey).(user.User)
if !ok {
panic("missing user value in request context")
}
return user
}
In this particular application, the code is structured in such a way that the contextGetUser()
function is only ever called when we logically expect there to be a user value in the request context. In this application, a missing value is firmly an programmer error and indicates that there is something wrong with the codebase.
Yes, contextGetUser()
could return an error instead of panicking. The error is certainly recoverable — the caller could cease further operations, log the error and send the user a 500 Internal Server Error
response. But this function gets called a lot, and it felt like returning an error would introduce excessive error handling for something that we should never see during normal operation. On balance, using panic()
here felt appropriate.
Example two
Here's another example from the same application:
func getEnvInt(key string, defaultValue int) int {
value, exists := os.LookupEnv(key)
if !exists {
return defaultValue
}
intValue, err := strconv.Atoi(value)
if err != nil {
panic(err)
}
return intValue
}
In this application, getEnvInt()
is a helper function used to read a value from an environment variable and convert it to an int
. If the conversion fails, then it panics.
At first glance, this might not seem like a suitable place to use panic()
. An error when trying to convert a specific environment variable to an int
seems like something outside of our program's control — an operational error. And it is.
But in this case, the getEnvInt
function is used (and only used) right at the start of the program to load configuration settings from the environment, like so:
httpPort := getEnvInt("HTTP_PORT", 3939)
At this early stage of the program, the logger (which also happens to rely on environment settings) hasn't been initialized. Since the program can't run without valid configuration values, and there's no proper logger available yet to handle errors gracefully, there aren't any other good options on the table for managing this error. Resorting to panic()
feels like a reasonable choice.
It fits the scenario of you don't want the program to continue and there are no better options for dealing with the error.
Example three
This is an example of where I've previously used panic()
in a guard clause.
var safeChars = regexp.MustCompile("^[a-z0-9_]+$")
type SortValues struct {
Column string
Ascending bool
}
func (sv *SortValues) OrderBySQL() string {
if !safeChars.MatchString(sv.Column) {
panic("unsafe sort column: " + sv.Column)
}
if sv.Ascending {
return fmt.Sprintf("ORDER BY %s ASC", sv.Column)
}
return fmt.Sprintf("ORDER BY %s DESC", sv.Column)
}
In this particular application, there was a need to generate SQL queries with dynamic ORDER BY
parameters based on user input. Unfortunately, SQL doesn't support placeholder parameters in ORDER BY
clauses, so we have to use string interpolation to insert the column name and sort direction into the query instead.
The SortValues
type holds the user-provided column name and sort direction, and its OrderBySQL()
method returns a string like ORDER BY title ASC
.
By the time that the OrderBySQL()
method is called, one of the upstream functions should have already validated the SortValues.Column
value against a whitelist of allowed column names. But if a bug, or oversight, ever caused that validation step to be missed, the application would be vulnerable to a SQL injection attack via the user-provided column name.
So, as a last-ditch mitigation, we use a panicking guard clause in OrderBySQL()
to ensure that the SortValues.Column
value only contains 'safe' characters (a to z, 0 to 9, and underscores).
We never expect this check to fail, so returning an error from OrderBySQL()
seems like overkill. But if it ever did happen, it feels better to trigger a panic than risk compromising the database.
Summary
So, let's answer the title of this post: When is it OK to panic in Go?
Your default should always be to return errors to the caller — or handle them gracefully then-and-there. "Don't panic" is a good guideline to almost always follow.
But panic()
isn't inherently bad, and using it is appropriate when:
- Your program encounters a programmer error and there is no way to manage it safely in a more graceful way.
- Your program encounters a programmer error and returning it to the caller would add an unacceptable amount of complexity or error handling to the rest of your codebase.
- You have a last-ditch 'guard clause' to prevent a particular operation happening when it shouldn't.
- Your program can't continue and there are simply no better options for dealing with the error in a more graceful way.