What Is Error Handling in Odin
Error handling in Odin revolves around a philosophy of explicit error propagation. Rather than using exceptions that unwind the call stack invisibly, Odin makes error states a first-class part of a function's return signature. The language provides multiple return values, lightweight propagation operators, and panic/recover mechanisms that give you fine-grained control over how failures are detected and handled.
At its core, error handling in Odin is about returning an error indicator alongside the primary result and forcing the caller to reckon with that indicator before proceeding. This design eliminates the "forgotten error check" problem that plagues many exception-based languages.
Why It Matters
🚀 Deploy your AI agent in 10 minutes
Managed Hermes hosting. Zero DevOps. 100M tokens/mo included.
Try it free →Explicit error handling matters for three critical reasons:
- Visibility — Every function signature advertises exactly what can go wrong. You never wonder whether a call might throw unexpectedly.
- Locality — Error checks happen at the call site, not in a distant
catchblock. This makes control flow easy to reason about. - Performance — Odin's error handling has zero hidden cost. There is no stack unwinding machinery, no exception tables, and no runtime type inspection unless you explicitly opt into it with panic/recover.
In systems programming—where Odin shines—you often operate close to the hardware. A missed error check can corrupt memory, leak resources, or crash a production server. Odin's patterns make it hard to ignore errors without writing deliberately opaque code.
Core Error Handling Patterns
Multiple Return Values
The foundational pattern is returning a result and an error indicator from a function. The error is typically the last return value and can be a boolean, an integer error code, an enum, or a pointer to a richer error struct.
package main
import "core:fmt"
// Simple boolean error indicator: true means "there is an error"
read_file :: proc(path: string) -> ([]byte, bool) {
// Simulated failure
if path == "" {
return nil, true // error = true
}
data := []byte("file contents here")
return data, false // error = false, success
}
main :: proc() {
data, err := read_file("config.txt")
if err {
fmt.println("Failed to read file")
return
}
fmt.println("Read", len(data), "bytes")
}
This pattern scales naturally. You can return more specific error information by using an enum or a struct as the error type:
package main
import "core:fmt"
import "core:os"
File_Error :: enum {
None,
Not_Found,
Permission_Denied,
Is_Directory,
}
open_config :: proc(path: string) -> (os.Handle, File_Error) {
if path == "" {
return os.Handle{}, File_Error.Not_Found
}
// Simulate success
return os.Handle{/* ... */}, File_Error.None
}
main :: proc() {
handle, err := open_config("config.toml")
if err != File_Error.None {
fmt.println("Open failed with:", err)
return
}
fmt.println("Handle obtained successfully")
// Use handle...
}
The or_return Operator
Checking errors manually at every call site is safe but verbose. Odin provides the or_return operator to propagate errors upward with a single token. When you write or_return after a function call that returns multiple values, Odin checks whether the last return value is truthy. If it is, the current function immediately returns with those same return values, propagating the error to the caller.
package main
import "core:fmt"
import "core:os"
// A function that can fail
open_file :: proc(path: string) -> (os.Handle, bool) {
if path == "" {
return os.Handle{}, true // error = true
}
return os.Handle{}, false
}
read_all :: proc(path: string) -> ([]byte, bool) {
// or_return propagates the error automatically
handle := open_file(path) or_return
// If we reach here, open_file succeeded
// ... read from handle ...
data := []byte("simulated data")
return data, false
}
main :: proc() {
data, err := read_all("")
if err {
fmt.println("read_all failed")
return
}
fmt.println("Success:", string(data))
}
The or_return operator works with any truthy last return value—booleans, non-zero integers, non-nil pointers, and non-"none" enum values all qualify as errors. This makes it a universal propagation mechanism across your entire codebase.
The or_else Operator
Sometimes you don't want to propagate an error; you want to fall back to a default value. The or_else operator provides exactly that. If the last return value is truthy (indicating an error), or_else evaluates to the fallback expression you provide.
package main
import "core:fmt"
fetch_setting :: proc(key: string) -> (int, bool) {
if key == "timeout" {
return 30, false // success, no error
}
return 0, true // error: key not found
}
main :: proc() {
// Use or_else to provide a default when fetch_setting fails
timeout := fetch_setting("unknown_key") or_else 5
fmt.println("Timeout:", timeout) // prints "Timeout: 5"
retries := fetch_setting("timeout") or_else 3
fmt.println("Retries:", retries) // prints "Retries: 30"
}
You can also use or_else to chain alternative operations:
package main
import "core:fmt"
try_primary :: proc() -> (string, bool) {
return "", true // simulate failure
}
try_fallback :: proc() -> (string, bool) {
return "fallback data", false
}
main :: proc() {
result := try_primary() or_else try_fallback() or_else "everything failed"
fmt.println(result) // prints "fallback data"
}
Custom Error Types
For complex applications, a simple boolean or enum often isn't enough. Odin lets you define rich error types that carry contextual information about what went wrong.
package main
import "core:fmt"
import "core:strings"
// A detailed error struct
Detailed_Error :: struct {
code: int,
message: string,
cause: Maybe_Error, // nil if no underlying cause
}
Maybe_Error :: union {
^Detailed_Error,
}
// Helper to create an error
make_error :: proc(code: int, msg: string) -> Maybe_Error {
e := new(Detailed_Error)
e.code = code
e.message = msg
e.cause = nil
return e
}
// A function returning a detailed error
validate_age :: proc(age: int) -> (bool, Maybe_Error) {
if age < 0 {
return false, make_error(400, "age cannot be negative")
}
if age > 150 {
return false, make_error(400, "age exceeds reasonable range")
}
return true, nil // nil means no error
}
main :: proc() {
valid, err := validate_age(-5)
if err != nil {
e := err.(^Detailed_Error)
fmt.println("Validation failed:", e.message, "(code:", e.code, ")")
return
}
fmt.println("Age is valid:", valid)
}
Using a union type with a nil branch (Maybe_Error) gives you a clean nil-or-error semantics. Callers check for nil and then cast to the concrete error type when they need details. This pattern is memory-safe because the union discriminator tracks which type is stored.
Panic and Recover
For truly unrecoverable situations—programmer errors, invariant violations, or corrupted state—Odin provides panic and recover. A panic immediately stops normal execution and unwinds the stack, calling deferred statements along the way. If a recover is called within a deferred block, execution resumes after the panicking call.
package main
import "core:fmt"
risky_operation :: proc() {
defer {
if r := recover(); r != nil {
fmt.println("Recovered from panic:", r)
}
}
fmt.println("About to panic...")
panic("something went catastrophically wrong")
fmt.println("This line never executes")
}
main :: proc() {
risky_operation()
fmt.println("Program continues normally after recover")
}
Use panic sparingly. It is best reserved for conditions that the programmer must fix (like array index out of bounds in debug builds, or initialization failures at program startup). Routine operational errors should use return values and or_return.
Assertions
Assertions are a development-time tool for catching logic errors. An assert statement evaluates a condition and, if it's false, triggers a panic with a descriptive message. Assertions are typically stripped or disabled in release builds, making them zero-cost for production.
package main
import "core:fmt"
divide :: proc(a, b: int) -> int {
assert(b != 0, "division by zero is not allowed")
return a / b
}
main :: proc() {
result := divide(10, 2)
fmt.println("10 / 2 =", result)
// Uncommenting the next line would trigger an assertion panic
// result = divide(10, 0)
}
Assertions shine during development and testing. They catch mistakes early, document invariants directly in code, and cost nothing when disabled. Use them to enforce preconditions, postconditions, and internal consistency checks.
Best Practices
- Use or_return liberally for propagation — It reduces boilerplate and keeps error paths concise. Every
or_returnis a clear, grep-able signal that errors flow upward. - Prefer typed errors over boolean indicators — A boolean tells you that something failed, but an enum or struct tells you why. Rich error types make debugging and recovery far easier.
- Reserve panic for programmer errors — If the condition can be triggered by user input, network conditions, or file system state, use return values. If it can only happen due to a bug in your code, a panic (or assertion) is appropriate.
- Always check errors at the outermost layer — In a command-line tool, check errors in
main. In a GUI, check them at the event handler level. Never let an error silently disappear into the middle of your call stack. - Document error semantics — A comment like
// Returns (data, true) on failurecosts one line and saves the next developer ten minutes of source-diving. - Use defer for cleanup — When you acquire a resource (file handle, allocation, lock), immediately write a
deferto release it. This ensures cleanup happens even whenor_returncauses an early return.
package main
import "core:fmt"
import "core:os"
process_file :: proc(path: string) -> ([]byte, bool) {
handle, err := os.open(path)
if err != os.ERROR_NONE {
return nil, true
}
defer os.close(handle) // guaranteed cleanup
// or_return still triggers the defer above
data := os.read_all(handle) or_return
return data, false
}
main :: proc() {
data, read_err := process_file("data.txt")
if read_err {
fmt.println("Could not process file")
return
}
defer delete(data) // clean up dynamic allocation
fmt.println("File contents:", string(data))
}
Conclusion
Odin's error handling patterns give you a spectrum of tools—from lightweight multiple return values and propagation operators to structured error types and panic/recover for extreme cases. The language's design encourages you to handle errors explicitly, locally, and efficiently. By combining or_return for propagation, or_else for fallbacks, custom error types for rich context, and assertions for development-time invariants, you build systems where failure paths are as well-defined as success paths. This explicitness pays dividends in reliability, maintainability, and performance—exactly what systems programming demands.