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:

the-except-block.png

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/error
  • defer + 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!

References

Defer, Panic, and Recover - The Go Programming Language