Errors are an unavoidable part of programming; they happen regardless of how careful you are. Recognizing where errors might occur and managing them effectively is crucial to building reliable software. When developing our Python SDK, we prioritized safe and efficient error handling, which led us to explore various patterns across languages like Go and Rust. These languages treat errors as values rather than exceptions, offering alternative strategies that can be adapted to Python to improve clarity and robustness.
Many programmers are curious about the learning curve for Python. If you’re wondering about the time investment needed, this resource provides valuable insights into how long it typically takes to become proficient. Understanding these foundational aspects can help you decide whether to pursue advanced error handling techniques in Python or explore other languages like Rust or Go, which handle errors differently.
Handling errors as values involves designing functions that return either a successful result or an error indicator, rather than throwing exceptions. This approach encourages developers to consider all potential failure points explicitly, leading to more maintainable and self-documenting code. It also aligns with modern software development practices, especially when building complex applications or microservices, where clear error propagation is vital. If you’re interested in leveraging cutting-edge methodologies for Python application development, these next-generation services can help streamline your workflow.
Thrown Errors in Python
Traditionally, Python relies on exceptions to signal errors. When an error occurs, an exception is thrown, causing the current control flow to be interrupted and transferred to an exception handler if one exists. If not, the program terminates with a traceback. This mechanism is simple but can obscure the origin of errors, especially in large codebases. For example, consider the following function:
“`python
def upsert_thing(thing_id: str) -> Thing:
thing = get_thing(thing_id)
thing.set_name(“Doodad”)
update_thing(thing)
log_thing(thing)
return thing
“`
Without examining the implementation details of each called function, it’s impossible to determine which line might raise an exception. Proper documentation of potential exceptions can mitigate this, but since Python’s exception handling isn’t enforced by the language, it remains unreliable. Java improves upon this by requiring explicit declaration of thrown exceptions in method signatures, but Python leaves it to the developer’s discipline.
To handle errors more safely, developers often wrap each call in try/except blocks:
“`python
def upsert_thing(thing_id: str) -> Thing:
try:
thing = get_thing(thing_id)
except Exception as err:
# If the get_thing call fails, create a new Thing
thing = Thing(thing_id)
try:
thing.set_name(“Doodad”)
except Exception as err:
raise Exception(f”Failed to set name: {err}”) from err
try:
update_thing(thing)
except Exception as err:
raise Exception(f”Failed to update: {err}”) from err
try:
log_thing(thing)
except Exception:
# Logging failures are non-critical
pass
return thing
“`
However, this verbose pattern makes the code cumbersome. Most Python developers prefer a more concise approach, often wrapping the entire sequence in a single try/except:
“`python
def upsert_thing(thing_id: str) -> Thing:
try:
thing = get_thing(thing_id)
thing.set_name(“Doodad”)
update_thing(thing)
log_thing(thing)
except Exception as err:
raise Exception(f”An error occurred: {err}”)
return thing
“`
This method simplifies error handling but sacrifices granularity and clarity about where errors originate. It also encourages coarse-grained try/except blocks, which can obscure specific failure points. Consequently, many engineers find this approach less than ideal, prompting the search for alternative patterns.
Errors as Values in Other Languages
Languages like Go and Rust adopt a different philosophy: functions return errors as values rather than throwing exceptions. This paradigm ensures that error handling is an explicit part of the function’s interface, compelling developers to consider and handle potential failures explicitly.
In Go, functions typically return a tuple with the result and an error:
“`go
// Retrieve a user or return an error
func getUser(userID string) (*User, error) {
rows := users.Find(userID)
if len(rows) == 0 {
return nil, errors.New(“user not found”)
}
return rows[0], nil
}
func renameUser(userID, name string) (*User, error) {
user, err := getUser(userID)
if err != nil {
return nil, err
}
user.Name = name
return user, nil
}
“`
Rust employs a `Result` type that encapsulates either a success (`Ok`) or failure (`Err`):
Interesting:
“`rust
// Function returning a Result with a User or an error string
fn get_user(user_id: &str) -> Result {
match find_user_by_id(user_id) {
Some(user) => Ok(user),
None => Err(“user not found”),
}
}
fn rename_user(user_id: &str, name: String) -> Result {
match get_user(user_id) {
Ok(mut user) => {
user.name = name;
Ok(user)
}
Err(e) => Err(e),
}
}
“`
By returning errors explicitly, these languages make error scenarios more transparent and manageable. They also promote thorough handling of all failure points, reducing bugs and undefined behaviors.
Implementing Errors as Values in Python
Python doesn’t natively support returning errors as values, but similar patterns can be adopted. The simplest method is to return a tuple containing the result and an error indicator:
“`python
def get_user(user_id: str) -> tuple[User | None, Exception | None]:
rows = users.find(user_id)
if len(rows) == 0:
return None, Exception(“User not found”)
return rows[0], None
def rename_user(user_id: str, name: str) -> tuple[User | None, Exception | None]:
user, err = get_user(user_id)
if err is not None:
return None, err
assert user is not None
user.name = name
return user, None
“`
This approach forces the caller to check for errors after each call, making error handling explicit but somewhat verbose and less elegant. The main drawback is that static type checkers cannot always infer that only one of the tuple’s values will be non-None, so manual assertions are often necessary.
A more sophisticated technique involves using external libraries like `result`, which provides pattern matching and more idiomatic handling:
“`python
import result
def get_user(user_id: str) -> result.Result[User, Exception]:
rows = users.find(user_id)
if len(rows) == 0:
return result.Error(Exception(“user not found”))
return result.Ok(rows[0])
def rename_user(user_id: str, name: str) -> result.Result[User, Exception]:
match get_user(user_id):
case result.Ok(user):
user.name = name
return result.Ok(user)
case result.Err(err):
return result.Err(err)
“`
While this improves clarity, it introduces external dependencies and requires pattern matching, which is only available in Python 3.10 and later. Moreover, many developers are hesitant to adopt newer syntax or external libraries.
The most Pythonic solution, leveraging the language’s dynamic typing, is to return a union type:
“`python
def get_user(user_id: str) -> User | Exception:
rows = users.find(user_id)
if len(rows) == 0:
return Exception(“user not found”)
return rows[0]
def rename_user(user_id: str, name: str) -> User | Exception:
user = get_user(user_id)
if isinstance(user, Exception):
return user
user.name = name
return user
“`
This approach uses `isinstance()` to narrow the type, allowing the code to handle errors seamlessly without extra boilerplate. It’s concise, intuitive, and aligns with Python’s flexible type system.
Final Thoughts
Inngest’s Python SDK adopts the pattern of handling errors as values because it integrates error management directly into the control flow, making error handling explicit and predictable. Although this approach can lead to more verbose code, it ensures comprehensive handling of error cases, improving overall robustness. Using union types to represent either a successful result or an error is the most idiomatic and succinct method in Python, balancing clarity and efficiency.
For those interested in exploring how this relates to learning Python, you can find resources on the typical learning timeline here. If you’re considering building advanced Python applications, insights into the latest development trends are available through these next-gen development services. And if you’re debating whether Python’s syntax and concepts are difficult, this article offers helpful perspectives. Finally, when choosing between R and Python for data science projects, this comparison can guide your decision.

