← Back to DevBytes

Error Handling Patterns in Odin

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:

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

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.

🚀 Need a reliable AI agent for your project?

Deploy Hermes Agent in 10 minutes. Managed hosting, zero DevOps.

Get Started — $23.99/mo
← Back to all articles