A Modern and Explicit approach to Python Exceptions

13 minute read

Two of the most popular "modern" programming languages are Rust and Go. While these languages are quite different, both have a few things in common. First, they are loved by their users, are growing fast, and both forego exceptions as a way of handling errors, instead they treat errors like any other type/object.

Since Python is dynamically types or nowadays, "gradually typed", handling errors with exceptions makes sense. But in the modern Python era we can make use of type annotations to make error handling a bit more explicit. After all, "Explicit is better than implicit".

In this post we will be exploring some alternative ways of handling errors in Python based on the ways in which Rust and Go handle errors. But first, lets review how errors are handled in the three languages.

{: .notice--info} As I was writing this and working on the code I came across a much more fleshed out version of a python Rust result type so if you actually want to use this in your code I would recommend this package as it is much more fleshed out.

Traditional Error Handling in Python

Traditionally error handling in Python is handled through Exceptions. While there are many ways to handle these exceptions with differing levels of sophistication the basic structure looks like this:

def divide(x: float, y: float) -> float:
    if y == 0:
        raise ZeroDivisionError("Cannot divide by 0")
    return x / y

So here we check that the denominator is 0 (because we can't divide by 0) and if so, we will raise a special exception ZeroDivisionError with a custom message, otherwise we get on with our usual business of dividing x by y. When we call this function we would handle the error like:

try:
    value = divide(x, y)
except ZeroDivisionError:
    # handle here

Or if choose not to recover then we can just call the function and the Exception will cause the program to crash. This is all fine and is indeed the standard way of handling errors but it is a bit tricky to know that this function may raise this error. Even if is provided in the documentation (which it is best practice to document any exceptions that could be raised) there is nothing for any linters or static type checkers to pick up on that will let a user know that the divide function could raise an error. This is where Rust and Go do something different as we will now see.

Error Handling in Rust

In Rust errors are handles by using the Result type which is actually a rust enum containing two variants, an Ok representing success and an Err representing an error. We specify in the return type the values stored inOk and Err, respectively. Our divide function would look something like this in Rust:

fn divide(x: f64, y: f64) -> Result<f64, &'static str> {
    if y == 0.0 {
        return Err("Cannot divide by 0");
    }
    Ok(x / y)
}

If we call this function we can handle this in many different ways but a few common ways would be

// handle via pattern matching
match divide(x, y) {
    Ok(v) => println!("The result is {}", v),
    Err(e) => println!("Error in divide: {}", e),
}

// handle via a method on Result that will return the value is successful or
// panic if there was an error
let value = divide(x, y).unwrap();

There are many many other ways to handle this and don't worry about the syntax if you are unfamiliar with Rust, the main point is that value is not itself the value x/y but an enum of type Result with two variants, Ok(x/y) or Err("Cannot divide by 0"). This forces the caller to handle any potential errors explicitly. The second method by calling unwrap() is most similar to just calling the function in python and letting it raise an error if it indeed throws that error; however, calling unwrap is generally bad practice in Rust. Nevertheless, a caller is still aware that the function can fail just by the fact that the return type is a Result.

Error Handling in Go

In Go, errors are handled very explicitly. Instead of returning a single type like in Rust, Go returns both the value and an error. If there is no error then it will be set to nil. While this is more verbose than Rust it does follow the same principle of explicitly leaving error handling up to the caller by putting the error in the return of the function. Our division function would be written something like this:

import (
	"errors"
)

func Divide(x, y float64) (float64, error) {
	if y == 0.0 {
		return 0.0, errors.New("Cannot divide by 0")
	}
	return x / y, nil
}

Just like in the above cases we check for the error case of y=0 and then return a placeholder value and the error. If there is no error then we return the real value and nil. Then when calling this function we would do something like

import (
    "fmt"
)

value, err := Divide(x, y)

// it is always best practice to do this check
// the value should not be trusted if this check is not done
if err != nil {
    fmt.Println("An error has occured:", err)
    return
}
fmt.Println("Dividend is:", value)

Just like with Rust, the user will explicitly know that this function can fail, just by the fact that it returns a value an a possible error. This check of if err != nil is ubiquitous in Go code and is the main way to check for errors. One difference with Rust is that a user could still go ahead and use the value even if there is an error but that wouldn't be very smart and is not recommended.

A Go-like error handling pattern in Python

We have seen the basics of how errors are handled in Rust and Go. Lets see how we can make our Python error handling more Go-like.

The simplest way to do this is to re-write our function like:

from typing import Optional, Tuple


def divide(x: float, y: float) -> Tuple[float, Optional[ZeroDivisionError]]:
    if y == 0:
        return 0, ZeroDivisionError("Cannot divide by 0")
    return x / y, None

Here we return two values just like in our Go code, the actual value or a placeholder along with a ZeroDivisionError or None. In this case we still make use of the builtin exceptions because hey, we are using Python! For the typing, we have to specify the error as Optional since we can also return None. When using this function we can follow this pattern

value, err = divide(x, y)

if err is not None:
    raise err

print(f"Dividend is: {value}")

Looks almost just like the Go code! Just as in our example above we can still use the value without the check but at least we know that this can return an error by the return signature and types. In this case since we are still using Python exceptions we can raise the error if we don't have any way of avoiding it. This is similar to the try-except statement from above but now we explicitly use the error as a value and not just something that may happen when calling the function.

Just for fun, we can make this a bit cleaner using python generics!

T = TypeVar("T")
E = TypeVar("E", bound=Exception)

Result = Tuple[T, Optional[E]]


def divide(x: float, y: float) -> Result[float, ZeroDivisionError]:
    if y == 0:
        return 0, ZeroDivisionError("Cannot divide by 0")
    return x / y, None

In Rust-like fashion we now return a Result type that is just a type alias for a tuple of any type and an option error that is a subclass of Exception. While this does the same thing it is less verbose and could represent an idiomatic way of doing things.

If we don't want to specify the exact exception type we could make another type alias:

from typing import Optional, Tuple, TypeVar

T = TypeVar("T")

ResultWithErr = Tuple[T, Optional[Exception]]


def divide(x: float, y: float) -> ResultWithErr[float]:
    if y == 0:
        return 0, ZeroDivisionError("Cannot divide by 0")
    return x / y, None

This just tells us that we are returning a result and an error, where the error is an Exception of some kind.

These type aliases could be nice to cut down on boilerplate but possibly make things a bit less explicit. In any case, Python offers a lot of options in terms of typing and actually returning errors.

Doing error handling the Go way is pretty straightforward in Python and is definitely more explicit than the try-except methods.

A Rust-like error handling pattern in Python

While implementing errors Go-style was pretty straightforward, to get a Rust-like system will be a bit more work. So, in Rust-land an enum can hold values and have methods defined on it. In rust, the Result enum looks like:

enum Result<T, E> {
   Ok(T),
   Err(E),
}

where T and E are generic types. One could then implement methods on this enum. In Python things don't quite work that way, so to mimic the Result type we will instead define two generic classes Ok[T,E] and Err[T, E] and use a type alias of Union to make a Result[T, E]. In the end we will have a Rust-like error handling system with a small subset of the Rust functionality. Lets start with the Ok class

from typing import Any, Callable, Generic, TypeVar

T = TypeVar("T")
E = TypeVar("E", bound=BaseException)


class Ok(Generic[T, E]):
    _value: T
    __match_args__ = ("_value",)

    def __init__(self, value: T):
        self._value = value

    def __eq__(self, other: Any) -> bool:
        if isinstance(other, Ok):
            return self._value == other._value  # type: ignore
        return False

    def unwrap(self) -> T:
        return self._value

    def unwrap_or(self, default: T) -> T:
        return self.unwrap()

    def unwrap_or_else(self, op: Callable[[E], T]) -> T:
        return self.unwrap()

    def __repr__(self) -> str:
        return f"Ok({repr(self._value)})"

Ok (get it!) lets unpack this a bit. First this class is generic (since it inherits from Generic) over a general type variable T and a bound type variable E. In this case we place a bound on E so that it must be a subclass of BaseException. This is different than Rust where anything can be used as an error but Python already has exceptions so we may as well use them. Next we see that this class is initialized with a value of type T which can be any type. We define the magic method __eq__ to return True if the contained values match in another instance of Ok. This will be important later when performing pattern matching or any other equality tests. We have also defined a nice repr on this class so that it is easily inspected.

Now, we have some methods on this class which are meant to mirror the functionality of the Rust Result enum. Before we get into the methods, lets define the Err class since it will have the same methods.

class Err(Generic[T, E]):
    _err: E
    __match_args__ = ("_err",)

    def __init__(self, err: E):
        self._err = err

    def __eq__(self, other: Any) -> bool:
        if isinstance(other, Err):
            return self._err == other._err  # type: ignore
        return False

    def unwrap(self) -> NoReturn:
        raise self._err

    def unwrap_or(self, default: T) -> T:
        return default

    def unwrap_or_else(self, op: Callable[[E], T]) -> T:
        return op(self._err)

    def __repr__(self) -> str:
        return f"Err({repr(self._err)})"

Just as in the Ok case this class is generic over T and E. It is also initialized with an err of type E (must be a subclass of BaseException) And it defined __eq__ in an analogous way to Ok.

Now for the methods. Well, first lets define our result type:

Result = Union[Ok[T, E], Err[T, E]]

This is just a type-alias of Union. So if we do Result[float, ZeroDivisionError] this means that a function/method will return either Ok(float) or Err(ZeroDivisionError). Since we can return either we must have exactly the same methods on both the Ok and Err classes. This is as close as we can get to defining methods on a single enum as we can do in Rust. Without further ado lets look at the methods and how they would be used in our canonical example.

def divide(x: float, y: float) -> Result[float, ZeroDivisionError]:
    if y == 0:
        return Err(ZeroDivisionError("Cannot divide by 0"))
    return Ok(x / y)

where we return Err if y=0 and wrap our output in Ok if not just like our Rust example above. When we use this function we can handle the errors in multiple ways

# handle via pattern matching (very Rusty!, but only in python >= 3.10)
match divide(x, y):
    case Ok(v): print(f"The value is {v}")
    case Err(e): print(f"The error is {e}")

# unwrap the result and let an error be raised if the function failed
value = divide(x, y).unwrap()

The first method here uses Python's new structural pattern matching which has a syntax very similar to Rust. Note, the __match_args__ class attributes in Ok and Err lets us match on the values without providing the keyword. The second method here calls the unwrap method, which, as we can see from the code above, will just return the value if Ok and will raise the error if the function errored. This is nearly equivalent to just raising the error in the function itself (the standard python way) but now at least we know the function can error by the Result return type and the fact that we must call unwrap. However, there are more things we could do.

We can provide a default value to fall back on if the function errors

# this will return the Ok value if no error, otherwise it will return the default (0.0)
value = divide(x, y).unwrap_or(0.0)

We can also provide a callable to try to recover from the error

x, y = 1.0, 0.0
EPS = 1e-9
value = divide(x, y).unwrap_or_else(lambda e: x / (y + EPS))

In this case we supply a function that takes in the error (in this case we don't actually use the error value but we could) and returns the division again but with a small offset to the zero denominator. This may or not be a good idea in this case but it illustrates the point. This method unwrap_or_else will return the Ok value if there is no error, otherwise it will call the supplied function.

It is not shown directly here but all of this is fully typed and will work with type checkers in and intellisense!

So this was a bit more work but we have partially replicated some of the Rust functionality and we have made error handling explicit!

Summary

Python handles errors by raising exceptions which can then be caught in downstream callers. However, since Python is becoming gradually typed it makes sense to think of alternate ways to handle errors that are made explicit through the type system. In this post we have looked at how two modern languages, Rust and Go, handle errors and showed how we can do similar things in Python.

In summary:

  • Go handles errors by returning the value alongside the error. We can do this in Python by returning a tuple or the value and a possible error. We then check for this error explicitly in our downstream code and we know about it from the return type.

  • Rust handles errors by returning an enum with two variants Ok or Err. The caller then must pull out the value or error through various different methods. We can replicate this in python by returning a single value which is a type alias of Union. A downstream caller then will "unwrap" this in ways similar to Rust.

Lastly, I will mention that neither of these approaches is likely to become mainstream simply because of the massive code bases already written and the now idiomatic way of handling exceptions; however it may still be useful to use these methods on stand-alone projects or to just think about alternatives!

Updated:

Leave a Comment