Understanding Go's Defer, Panic(), Recover() for Exception/Error Handling through Python's Try Except
Posted on Mar 27, 2024 in Go Programming - Beginner Level by Amo Chen ‐ 7 min read
During the process of learning Go, many might find themselves puzzled by the way Go handles exceptions/errors using defer(), panic(), recover(). Especially for developers already familiar with languages like Java, JavaScript, and Python, the try...catch
, try-except
mechanisms seem more intuitive and readable during development.
However, understanding Go’s defer(), panic(), recover() can be easier if we start from familiar patterns. By doing so, what initially seems complex may become much clearer.
This article will help you learn how Go handles exceptions from the perspective of Python’s try-except
mechanism.
Environment
- Python 3
- Go
From Python’s try-except to Go’s defer, panic(), recover()
Python’s try-except
structure captures exceptions or errors thrown within a try
block and executes code inside the except
block when an exception or error is caught. The following pattern is quite common in Python for error handling:
try:
# Code that might cause an error
except Exception as e:
if isinstance(e, ValueError):
# Handle the error
else:
# Handle the error
For instance, in the following example function, a ValueError
is directly raised within the try
block, and the except
block catches the exception, printing f'Got an exception: {e}'
:
def example():
try:
raise ValueError()
except Exception as e:
print(f'Got an exception: {e}')
The equivalent code in Go would look like this:
package main
import "fmt"
func example() {
defer func() {
if r := recover(); r != nil {
fmt.Println("Got an exception:", r)
}
}()
panic("value error")
}
func main() {
example()
}
Visualizing this can make it easier to understand the similar parts between Python and Go:
Go’s panic()
is essentially equivalent to Python’s raise
, meaning to throw an exception/error. After throwing an exception/error, there needs to be a way to capture it for further processing, which is where recover()
comes in.
recover()
must be placed within a defer
function due to Go’s specifications:
Recover is a built-in function that regains control of a panicking goroutine. Recover is only useful inside deferred functions. During normal execution, a call to recover will return nil and have no other effect. If the current goroutine is panicking, a call to recover will capture the value given to panic and resume normal execution.
If recover()
is not called within a defer
function, or if no panic occurs, recover()
simply returns nil
.
The characteristic of defer
functions is that they execute before the caller returns. Therefore, if a panic occurs before returning, the recover()
within the defer
function can correctly catch the exception/error:
package main
import (
"fmt"
)
func caller() {
defer func() {
if r := recover(); r != nil {
// error handling
fmt.Println("error handling")
}
}()
fmt.Println("error happened")
panic("error happened")
}
func main() {
caller()
}
By now, we should understand the role of combining defer
, panic()
, and recover()
:
panic()
throws an exception/errordefer
+recover()
catches the exception/error
Other Common Exception Handling Patterns
From a Python try-except
perspective, we have learned the role of the combination of defer
, panic()
, and recover()
. However, for beginners learning Go, there might still be common exception handling patterns that aren’t immediately obvious. Let’s decode them one by one.
Returning a Value Regardless of What Happens
In some cases, certain errors can be ignored, and you just need to log the error. For example, in the following Python code, we simply print a warning message for the error and then return a value:
def get_value_no_matter_what_happened():
try:
raise ValueError()
except Exception as e:
print('Warning: there is an error here')
return 1
In Go, the equivalent approach is less intuitive because after calling panic()
, you cannot return to the original program flow. For example, in this Go example, fmt.Println("Hello")
is never executed:
package main
import "fmt"
func main() {
defer func() {
if r := recover(); r != nil {
fmt.Println("error handling")
}
}()
panic("error")
fmt.Println("Hello") // <- unreachable code
}
To solve this, you can use named return values. By specifying a name for the return value in the function definition, you can modify it within the defer
function. In the following example, (ret int)
demonstrates the use of named return values:
package main
import "fmt"
func getValueNoMatterWhatHappened() (ret int) {
defer func() {
if r := recover(); r != nil {
fmt.Println("Warning: error here")
ret = 1
}
}()
panic("value error")
}
func main() {
fmt.Println("getValueNoMatterWhatHappened:", getValueNoMatterWhatHappened())
}
The result of the above example shows that even if a panic occurs in getValueNoMatterWhatHappened()
, we can still return a value using named return values:
Warning: error here
getValueNoMatterWhatHappened: 1
This ability is thanks to the defer
function’s capability to legally read and modify the named return values:
Deferred functions may read and assign to the returning function’s named return values.
Where’s That Finally Block?
A finally clause is always executed before leaving the try statement, whether an exception has occurred or not.
Python’s try-except
also has a finally
clause, which executes when leaving the try-except
block, regardless of whether an exception occurred. This is ideal for releasing resources, such as unlocking to prevent deadlocks.
Here’s an example of a Python try-except-finally
:
def get_value_no_matter_what_happened():
try:
raise ValueError()
except Exception as e:
print('the exception block executed')
return 1
finally:
print('the finally block executed')
The example’s output shows that the finally
block executes even after the return 1
statement:
>>> x = get_value_no_matter_what_happened()
the exception block executed
the finally block executed
>>> x
1
In Go, you can achieve similar behavior by adding another defer function within the main defer function:
package main
import "fmt"
func getValueNoMatterWhatHappened() (ret int) {
defer func() {
defer func() {
fmt.Println("the finally block executed")
}()
if r := recover(); r != nil {
fmt.Println("the exception block executed")
ret = 1
}
}()
panic("value error")
}
func main() {
fmt.Println("getValueNoMatterWhatHappened:", getValueNoMatterWhatHappened())
}
P.S. You can also modify the ret
value inside the innermost defer function, similar to returning from a Python finally
block.
Allowing the Program to Continue
Previously, we mentioned that after calling panic()
, returning to the normal program flow isn’t possible. However, in Python, once the except
block handles an error, the program can continue. For example, the print('do the rest of the jobs')
line executes after the error since it’s been handled by try-except
:
def do_something_no_matter_what_happened():
try:
print('do something')
raise ValueError()
except Exception as e:
print('do something if an error occurred')
print('do the rest of the jobs')
In Go, functions can return an error value, allowing you to determine if an error occurred and handle it accordingly. This pattern is common in Go code:
err := doX()
if err != nil {
// error handling
}
The previous Python example is equivalent to this Go code:
package main
import (
"errors"
"fmt"
)
func doSomething() error {
fmt.Println("do something")
return errors.New("value error")
}
func getValueNoMatterWhatHappened() {
err := doSomething()
if err != nil {
fmt.Println("do something if an error occurred")
}
fmt.Println("do the rest of the jobs")
}
func main() {
getValueNoMatterWhatHappened()
}
However, whether doSomething()
handles the error within itself or in the place where doSomething()
is called is a choice you can make.
err := doSomething()
if err != nil {
fmt.Println("do something if an error occurred")
}
You can also choose to encapsulate the error-prone code in a function where it handles errors internally:
package main
import "fmt"
func doSomething() {
defer func() {
if r := recover(); r != nil {
fmt.Println("do something if an error occurred")
}
}()
fmt.Println("do something")
panic("value error")
}
func getValueNoMatterWhatHappened() {
doSomething()
fmt.Println("do the rest of the jobs")
}
func main() {
getValueNoMatterWhatHappened()
}
The result of the above example shows that both methods achieve the desired outcome:
do something
do something if an error occurred
do the rest of the jobs
Conclusion
The design of Go’s defer, panic(), and recover() may differ from Java, JavaScript, Python, and other programming languages, but they accomplish similar tasks. For those of us accustomed to a particular pattern, starting from familiar examples helps in understanding Go’s defer, panic(), and recover() design more thoroughly.
That’s all!
Enjoy!