Skip to main content

Errors, error handling, and context managers

Mojo represents errors as values—specifically, as alternate return values from functions. Unlike stack-unwinding exceptions in languages like C++ or Java, Mojo errors don't require expensive call stack unwinding, so their runtime overhead is as low as returning and checking an extra Bool. This design also enables error handling in contexts where traditional exceptions aren't available, like GPU kernels.

This page covers:

An error interrupts the normal execution flow of your program. If you provide an error handler (using try/except) in the current function, execution resumes with that handler. If the error isn't handled in the current function, it propagates to the calling function, and so on. If an error isn't caught by any handler, your program terminates with a non-zero exit code and prints the error message:

Unhandled exception caught during execution: record not found

Raise an error

The built-in Error type is the default error type for most Mojo code. It carries a text message describing what went wrong, and it's the right choice for application-level error handling—simple, well-supported, and sufficient for the majority of use cases.

You can raise an Error with the constructor or a string literal shorthand:

# These are equivalent
raise Error("file not found")
raise "file not found"

The string literal form is a convenience—the compiler automatically wraps it in an Error.

A def function is raising by default and always uses the built-in Error type. You don't include raises in the signature (doing so results in a compiler error):

def read_file(path: String) -> String:
    if not path:
        raise "path cannot be empty"
    return "contents of " + path

An fn function can also raise Error by using bare raises without a type:

fn read_file_fn(path: String) raises -> String:
    if not path:
        raise "path cannot be empty"
    return "contents of " + path

Handle an error

Mojo uses try/except to detect and handle errors. The full syntax is:

try:
    # Code that might raise an error
except e:
    # Runs if an error occurs
else:
    # Runs if no error occurs
finally:
    # Always runs, regardless of outcome

You must include one or both of except and finally. The else clause is optional.

How each clause works

  • try — Contains code that might raise an error. If no error occurs, the entire block executes. If an error occurs, execution stops at the raise point and continues with the except clause (if present) or the finally clause.

  • except — Runs only when an error occurs in the try block. If you provide a variable name (except e:), the error is bound to that variable. A try block can have only one except clause.

  • else — Runs only when no error occurs in the try block. The else clause is skipped if the try clause exits via continue, break, or return.

  • finally — Runs after the try and any except or else clause, regardless of outcome. It executes even if another clause exits via continue, break, return, or by raising a new error. Use finally to release resources (such as file handles) that must be cleaned up regardless of whether an error occurred.

Example

The following example demonstrates all four clauses. The process_record() function raises Error for different conditions, and the caller loops over a list of IDs to exercise each clause:

handle_error.mojo
fn process_record(id: Int) raises -> String:
    if id < 0:
        raise Error("invalid record ID: must be non-negative")
    if id > 999:
        raise Error("record not found")
    return String("record_", id)


def main():
    try:
        for id in [5, 0, 1001, -3, 42]:
            var result: String
            try:
                print()
                print("try     => id:", id)
                if id == 0:
                    continue
                result = process_record(id)
            except e:
                if "invalid" in String(e):
                    print("except  => fatal:", e)
                    raise e^
                print("except  => handled:", e)
            else:
                print("else    => success:", result)
            finally:
                print("finally => done with id:", id)
    except e:
        print("\nre-raised error:", e)

try     => id: 5
else    => success: record_5
finally => done with id: 5

try     => id: 0
finally => done with id: 0

try     => id: 1001
except  => handled: record not found
finally => done with id: 1001

try     => id: -3
except  => fatal: invalid record ID: must be non-negative
finally => done with id: -3

re-raised error: invalid record ID: must be non-negative

Notice:

  • When id is 5: process_record() succeeds, so else runs, then finally.
  • When id is 0: continue exits the try block, skipping both except and else. Only finally runs.
  • When id is 1001: process_record() raises an error. The except clause handles it and execution continues with the next iteration.
  • When id is -3: process_record() raises an "invalid" error. The except clause re-raises it with raise e^, which transfers ownership of the error to the outer try/except. The finally clause still runs before the error propagates. Because the re-raise exits the loop, id 42 is never processed.

Re-raise an error

To re-raise a caught error, use raise with the transfer sigil (^) to transfer ownership of the error value:

try:
    result = process_record(-1)
except e:
    print("Logging error:", e)
    raise e^  # re-raise with ownership transfer

You can also raise a different error from within an except clause.

Typed errors

For code that needs more than a string message—like standard library APIs, GPU abstractions, or situations where callers need structured error data—Mojo lets you define custom error types as structs.

Define a custom error type

In Mojo, any struct can serve as an error type—no special base class or trait is required. However, implementing the Writable trait is recommended so the error produces a readable message when printed or when the program terminates with an unhandled error:

@fieldwise_init
struct ValidationError(Copyable, Writable):
    var field: String
    var reason: String

    fn write_to(self, mut writer: Some[Writer]):
        writer.write("ValidationError(", self.field, "): ", self.reason)

The @fieldwise_init decorator generates an __init__() method with an argument for each field, so you can construct errors like ValidationError("username", "too short") or with keyword arguments like ValidationError(field="username", reason="too short").

Raise a typed error

To declare that a function can raise a typed error, add raises YourErrorType to its signature:

fn validate_username(username: String) raises ValidationError -> String:
    if len(username) == 0:
        raise ValidationError(field="username", reason="cannot be empty")
    if len(username) < 3:
        raise ValidationError(
            field="username", reason="must be at least 3 characters"
        )
    return username

A function defined with fn is non-raising by default. Including raises (with or without a type) makes it a raising function. Each fn function can declare at most one error type. The compiler enforces this—if any raise statement in the function body doesn't match the declared type, the program won't compile.

If a non-raising fn calls a raising function, it must handle the error locally:

# This doesn't compile — validate_username() can raise
fn process_name(name: String):
    print(validate_username(name))

# This compiles — the error is handled
fn process_name_safe(name: String):
    try:
        print(validate_username(name))
    except e:
        print("Invalid:", e)

Catch a typed error

Use try/except to catch a typed error. The compiler automatically infers the error type from the function being called, so except e: gives you a fully typed error value—no casting required:

try:
    var name = validate_username("")
except e:
    # e is a ValidationError — access fields directly
    print("Error in field '" + e.field + "': " + e.reason)

Which produces this output:

Error in field 'username': cannot be empty

A try block can include only one except clause. Mojo doesn't support except ErrorType as e: syntax—the type is always inferred from the function being called.

If you need to handle calls that raise different error types, use separate try blocks:

# Each try block handles one error type
try:
    var name = validate_username(input)
except e:
    # e is a ValidationError — access fields directly
    print("Validation failed:", e.field, e.reason)

try:
    var file = open_file(path)
except e:
    # e is a FileError — match on variant
    if e == FileError.not_found:
        print("Missing:", path)

Representing multiple error conditions

Each fn function can declare only one error type in its raises clause. When a function can fail in multiple distinct ways, you need to represent those conditions within a single type. Mojo offers two approaches:

  • Enumerated error types — A single struct with comptime variant aliases. Simpler and more efficient when you only need to distinguish between conditions.
  • The Variant type — The standard library Variant type with separate structs per condition. More flexible when each condition needs to carry different data.

Enumerated error types

A single struct can represent all error conditions using an integer _variant field and comptime values as named constants. The write_to method generates human-readable strings from the variant code:

@fieldwise_init
struct FileError(Equatable, ImplicitlyCopyable, Writable):
    var _variant: Int

    # Compile-time constant variants
    comptime not_found = FileError(_variant=1)
    comptime permission_denied = FileError(_variant=2)
    comptime already_exists = FileError(_variant=3)

    fn variant_name(self) -> String:
        if self._variant == 1:
            return "not_found"
        elif self._variant == 2:
            return "permission_denied"
        elif self._variant == 3:
            return "already_exists"
        return "unknown"

    fn write_to(self, mut writer: Some[Writer]):
        writer.write("FileError.", self.variant_name())

Because FileError has a single Int field and conforms to Equatable, the compiler auto-synthesizes __eq__(), so you can compare variants directly:

fn open_file(path: String) raises FileError -> String:
    if not path:
        raise FileError.not_found
    if path == "/secret":
        raise FileError.permission_denied
    return "Contents of " + path

You can then match on specific variants in the handler:

try:
    print(open_file("/secret"))
except e:
    if e == FileError.not_found:
        print("Not found:", e)
    elif e == FileError.permission_denied:
        print("Permission denied:", e)

Which produces this output:

Permission denied: FileError.permission_denied

The Variant type

When each error condition needs to carry different data, you can use the standard library Variant type instead of an integer-based enumeration. Define a separate struct for each condition, then combine them into a single error type with a comptime alias:

variant_errors.mojo
from utils import Variant


@fieldwise_init
struct NotFoundError(Copyable, Writable):
    var path: String

    fn write_to(self, mut writer: Some[Writer]):
        writer.write("file not found: ", self.path)


@fieldwise_init
struct PermissionError(Copyable, Writable):
    var path: String
    var required_role: String

    fn write_to(self, mut writer: Some[Writer]):
        writer.write(
            "permission denied on ",
            self.path,
            " (requires ",
            self.required_role,
            ")",
        )


comptime FileError = Variant[NotFoundError, PermissionError]

Construct a Variant by wrapping the inner error in the Variant type:

fn open_file(path: String) raises FileError -> String:
    if not path:
        raise FileError(NotFoundError(""))
    if path == "/secret":
        raise FileError(PermissionError("/secret", "admin"))
    return "Contents of " + path

In the handler, use .isa[T]() to test which condition occurred and e[T] to access the inner error with its full type:

try:
    print(open_file("/secret"))
except e:
    if e.isa[NotFoundError]():
        print("Not found:", e[NotFoundError])
    elif e.isa[PermissionError]():
        print("Access denied:", e[PermissionError])
Access denied: permission denied on /secret (requires admin)

Use the Variant approach when each condition carries different fields (like path vs path + required_role above). Use the enumerated error type pattern when you only need to distinguish between conditions without carrying different data per condition.

The Never type

Never is a type with no constructors—it can't be instantiated. This makes it useful in error-handling signatures to express two opposite guarantees:

  • raises YourErrorType -> Never — The function always raises and never returns a value. This is useful for functions like panic() that unconditionally signal an error.
  • raises Never -> ReturnType — The function never raises and always returns a value. This is equivalent to omitting raises entirely.

Functions that always raise

A function with -> Never as its return type must never terminate with a return statement—it must raise on every code path (or loop infinitely). Because Never can substitute for any type, the compiler allows using such a function in place of a return value:

# Always raises, never returns
fn panic(msg: String) raises -> Never:
    raise Error(msg)

fn get_value_or_panic(maybe: Optional[Int]) raises -> Int:
    if maybe:
        return maybe.value()
    # Never substitutes for Int in this branch
    panic("value is missing")

Functions that never raise

A function with raises Never guarantees at compile time that it never raises. This is equivalent to writing a plain non-raising function:

# These two signatures are equivalent:
fn safe_add(a: Int, b: Int) raises Never -> Int:
    return a + b

fn safe_add(a: Int, b: Int) -> Int:
    return a + b

This equivalency is especially useful in combination with parametric raises, where the compiler infers raises Never when a function argument doesn't raise.

Parametric raises

You can write generic functions that propagate the error type from a function argument to the caller. This uses a compile-time parameter for the error type:

fn run_action[
    ErrorType: AnyType
](action: fn() raises ErrorType -> Int) raises ErrorType -> Int:
    return action()

The ErrorType parameter is inferred from the function you pass in. If the function raises NetworkError, then run_action raises NetworkError. If the function raises ParseError, then run_action raises ParseError:

fn fetch_data() raises NetworkError -> Int:
    raise NetworkError(code=404)

fn parse_config() raises ParseError -> Int:
    raise ParseError(position=42)

# ...

# ErrorType inferred as NetworkError
try:
    _ = run_action(fetch_data)
except e:
    print("Network failure:", e)

# ErrorType inferred as ParseError
try:
    _ = run_action(parse_config)
except e:
    print("Parse failure:", e)

If the function argument doesn't raise at all, the compiler infers Never as the error type. This means run_action itself becomes non-raising, and no try block is needed:

fn get_value() -> Int:
    return 99

# ...

# ErrorType inferred as Never — no try block needed
var result = run_action(get_value)
print("Got value:", result)

Which produces this output:

Got value: 99

fn vs def for error handling

Mojo has two function forms — def and fn — that differ in how they handle errors:

  • def — Implicitly raising. Always uses the built-in Error type. You can't specify a typed error.
  • fn with bare raises — Explicitly raising. Uses the built-in Error type, same as def.
  • fn with raises YourErrorType — Explicitly raising with a typed error. This is the only way to raise typed errors.

def functions

A def function can raise the built-in Error type without declaring it in the signature:

def validate_def(value: Int) -> Int:
    if value < 0:
        raise "value cannot be negative"
    return value

Because def already implies raises, you can't add a typed error annotation. The following produces a compiler error:

# Compiler error: function effect 'raises' was already specified
def validate_def(value: Int) raises ValidationError -> Int:
    ...

fn functions

An fn function is non-raising by default. To make it raise, add raises (for Error) or raises YourErrorType (for a typed error):

# fn with typed error — caller gets full access to error fields
fn validate_fn(value: Int) raises ValidationError -> Int:
    if value < 0:
        raise ValidationError(field="value", reason="cannot be negative")
    return value

Wrapping def in fn for typed errors

If you have existing def functions that you want to expose with typed errors, write an fn wrapper that catches the Error and converts it:

fn validated_operation(value: Int) raises ValidationError -> Int:
    try:
        return validate_def(value)
    except e:
        raise ValidationError(field="value", reason=String(e))

The wrapper calls the def function inside a try block, catches any Error, and re-raises it as a typed error. This pattern lets you add type safety at API boundaries without rewriting internal code:

try:
    _ = validated_operation(-1)
except e:
    print(e.field)   # prints: value
    print(e.reason)  # prints: value cannot be negative

Typed errors and Error interaction

Most codebases contain a mix of def functions (which use Error) and fn functions with typed errors. This section covers how the two styles interact and how to work with them effectively.

Wrap Error at API boundaries

When calling def functions or other Error-raising code from a function that uses typed errors, catch the Error and convert it:

def validate_with_error(value: Int) -> Int:
    if value < 0:
        raise "value cannot be negative"
    return value

fn wrapped_validate(value: Int) raises ValidationError -> Int:
    try:
        return validate_with_error(value)
    except e:
        raise ValidationError(field="value", reason=String(e))

This is the same wrapping pattern described in fn vs def for error handling, and it's the recommended way to integrate Error-based code into typed error APIs.

Avoid bare raises with typed errors

Using bare raises (without a type) on an fn that calls typed-error functions causes type erasure—the compiler forgets the specific error type, even though the runtime preserves the error's identity:

# Anti-pattern: bare raises erases type info at compile time
fn validate_bare_raises(value: Int) raises -> Int:
    return validate_typed(value)

The caller of validate_bare_raises() receives an Error, not a ValidationError:

try:
    _ = validate_bare_raises(-5)
except e:
    # e is typed as Error — no field access available
    # e.field would not compile here
    print(e)
ValidationError(value): cannot be negative

The error message still shows ValidationError because the runtime preserves the original error's Writable output. But the compiler sees only Error, so you lose access to structured fields. Always use raises YourErrorType to maintain type safety.

def functions can catch typed errors

A def function can call a function that raises a typed error and catch it with full field access:

def error_caller():
    try:
        _ = validate_typed(-5)
    except e:
        # e is a ValidationError — field access works
        print("Field:", e.field, "Reason:", e.reason)

Executing this function produces this output:

Field: value Reason: cannot be negative

However, if a def function calls code that raises a typed error without catching it, the typed error is subject to type erasure as described in Avoid bare raises with typed errors.

Don't mix error types in a single try block

You can't call functions that raise different error types in the same try block. The compiler rejects the mismatch:

def error_func() -> Int:
    raise "something went wrong"

fn typed_func() raises ValidationError -> Int:
    raise ValidationError(field="x", reason="invalid")

# This doesn't compile
fn mixed() raises ValidationError:
    try:
        _ = error_func()   # raises Error
        _ = typed_func()    # raises ValidationError
    except e:
        print(e)

The compiler reports:

error: cannot call function that may raise 'Error' in a context that
supports an error type of 'ValidationError'

To call both functions, use separate try blocks or wrap the Error-raising function as shown in Wrap Error at API boundaries.

Recommendations for mixed codebases

When working with both Error and typed errors:

  • Wrap at boundaries — Use fn wrappers to convert Error to typed errors at module boundaries, rather than rewriting all internal code at once.
  • Use raises YourErrorType — Always specify the error type in fn signatures. Bare raises discards type information.
  • Keep def for prototypingdef is convenient for quick prototyping, but convert to fn with typed errors for production APIs.
  • Use separate try blocks — When calling functions with different error types, use nested or sequential try blocks to handle each type independently.

Enable stack trace generation for errors

Because Mojo represents errors as alternate return values rather than stack-unwinding exceptions, stack trace collection isn't automatic. Collecting a stack trace requires heap allocation and adds runtime overhead, so it's disabled by default to keep error handling lightweight.

By default, Mojo generates a stack trace when your program hits a segmentation fault. You can disable this by setting the MOJO_ENABLE_STACK_TRACE_ON_CRASH environment variable to 0 or false (case-insensitive).

However, Mojo doesn't generate a stack trace when your program raises an error—this avoids the additional runtime overhead. To enable stack traces for raised errors, set the MOJO_ENABLE_STACK_TRACE_ON_ERROR environment variable to any value other than 0 or false (case-insensitive).

Keep in mind that when you compile your program with mojo build, the compiler optimizes and strips symbols by default, so often your stack trace won't be very useful.

Consider this program:

stacktrace_error.mojo
def func2() -> None:
    raise Error("Intentional error")


def func1() -> None:
    func2()


def main():
    func1()

If you compile the program with default settings and run it with the environment variable set, you'll see a stack trace without symbols:

mojo build stacktrace_error.mojo
MOJO_ENABLE_STACK_TRACE_ON_ERROR=1 ./stacktrace_error
#0 0x... llvm::sys::PrintStackTrace(llvm::raw_ostream&, int)
#1 0x... KGEN_CompilerRT_GetStackTrace
#2 0x... main (./stacktrace_error+...)

Unhandled exception caught during execution: Intentional error

To generate a more useful stack trace, compile the program with --debug-level full (or -g) to include debug symbols:

mojo build --debug-level full stacktrace_error.mojo
MOJO_ENABLE_STACK_TRACE_ON_ERROR=1 ./stacktrace_error
#0 0x... llvm::sys::PrintStackTrace(llvm::raw_ostream&, int)
#1 0x... KGEN_CompilerRT_GetStackTrace
#2 0x... Error.__init__[...](...) .../builtin/error.mojo:159:38
#3 0x... stacktrace_error::func2() stacktrace_error.mojo:14:16
#4 0x... stacktrace_error::func1() stacktrace_error.mojo:18:10
#5 0x... stacktrace_error::main() stacktrace_error.mojo:22:10
#6 0x... __wrap_and_execute_raising_main[...](...) .../builtin/_startup.mojo:88:18
#7 0x... main .../builtin/_startup.mojo:103:4

Unhandled exception caught during execution: Intentional error

With debug symbols, the trace shows the function call chain and source locations: main()func1()func2()Error.__init__().

Capture a stack trace programmatically

You can bind the Error instance to a variable in the except clause and call its get_stack_trace() method to get the stack trace as an Optional[String]. The method returns None if stack trace collection was disabled or unavailable:

stacktrace_error_capture.mojo
def func2() -> None:
    raise Error("Intentional error")


def func1() -> None:
    func2()


def main():
    try:
        func1()
    except e:
        print(e)
        print("-" * 20)
        var stack_trace = e.get_stack_trace()
        if stack_trace:
            print(stack_trace.value())
        else:
            print("No stack trace available")

When you compile with debug symbols and run with stack trace generation enabled:

mojo build --debug-level full stacktrace_error_capture.mojo
MOJO_ENABLE_STACK_TRACE_ON_ERROR=1 ./stacktrace_error_capture
Intentional error
--------------------
#0 0x... llvm::sys::PrintStackTrace(llvm::raw_ostream&, int)
#1 0x... KGEN_CompilerRT_GetStackTrace
#2 0x... Error.__init__[...](...) .../builtin/error.mojo:159:38
#3 0x... stacktrace_error_capture::func2() stacktrace_error_capture.mojo:14:16
#4 0x... stacktrace_error_capture::func1() stacktrace_error_capture.mojo:18:10
#5 0x... stacktrace_error_capture::main() stacktrace_error_capture.mojo:23:14
#6 0x... __wrap_and_execute_raising_main[...](...) .../builtin/_startup.mojo:88:18
#7 0x... main .../builtin/_startup.mojo:103:4

Without enabling stack trace generation, the output is:

Intentional error
--------------------
No stack trace available

Use a context manager

A context manager is an object that manages resources such as files, network connections, and database connections. It provides a way to allocate resources and release them automatically when they are no longer needed, ensuring proper cleanup and preventing resource leaks even when errors occur.

As an example, consider reading data from a file. A naive approach might look like this:

# Obtain a file handle to read from storage
f = open(input_file, "r")
content = f.read()
# Process the content as needed
# Close the file handle
f.close()

Calling close() releases the memory and other operating system resources associated with the opened file. If your program were to open many files without closing them, you could exhaust the resources available to your program and cause errors. The problem is even worse if you were writing to a file instead of reading from it, because the operating system might buffer the output in memory until the file is closed. If your program were to crash instead of exiting normally, that buffered data could be lost instead of being written to storage.

The example above includes the call to close(), but it ignores the possibility that read() could raise an error, which would prevent the close() from executing. To handle this scenario, you could rewrite the code to use try like this:

# Obtain a file handle to read from storage
f = open(input_file, "r")

try:
    content = f.read()
    # Process the content as needed
finally:
    # Ensure that the file handle is closed even if read() raises an error
    f.close()

However, the FileHandle struct returned by open() is a context manager. When used with Mojo's with statement, a context manager ensures that the resources it manages are properly released at the end of the block, even if an error occurs. In the case of a FileHandle, that means the call to close() takes place automatically. So you could rewrite the example above to take advantage of the context manager (and omit the explicit call to close()) like this:

with open(input_file, "r") as f:
    content = f.read()
    # Process the content as needed

The with statement also allows you to use multiple context managers within the same code block. As an example, the following code opens one text file, reads its entire content, converts it to upper case, and then writes the result to a different file:

with open(input_file, "r") as f_in, open(output_file, "w") as f_out:
    input_text = f_in.read()
    output_text = input_text.upper()
    f_out.write(output_text)

FileHandle is perhaps the most commonly used context manager. Other examples of context managers in the Mojo standard library are NamedTemporaryFile, TemporaryDirectory, BlockingScopedLock, and assert_raises. You can also create your own custom context managers, as described in Write a custom context manager below.

Write a custom context manager

Writing a custom context manager is a matter of defining a struct that implements two special dunder methods ("double underscore" methods): __enter__() and __exit__():

  • __enter__() is called by the with statement to enter the runtime context. The __enter__() method should initialize any state necessary for the context and return the context manager.

  • __exit__() is called when the with code block completes execution, even if the with code block terminates with a call to continue, break, or return. The __exit__() method should release any resources associated with the context. After the __exit__() method returns, the context manager is destroyed.

    If the with code block raises an error, then the __exit__() method runs before any error processing occurs (that is, before it is caught by a try/except structure or your program terminates). If you'd like to define conditional processing for error conditions in a with code block, you can implement an overloaded version of __exit__() that takes an error argument. For more information, see Define a conditional __exit__() method and Handle typed errors in __exit__() below.

    For context managers that don't need to release resources or perform other actions on termination, you are not required to implement an __exit__() method. In that case the context manager is destroyed automatically after the with code block completes execution.

Here is an example of implementing a Timer context manager, which prints the amount of time spent executing the with code block:

context_mgr.mojo
import sys
import time

@fieldwise_init
struct Timer(ImplicitlyCopyable):
    var start_time: Int

    fn __init__(out self):
        self.start_time = 0

    fn __enter__(mut self) -> Self:
        self.start_time = Int(time.perf_counter_ns())
        return self

    fn __exit__(mut self):
        end_time = time.perf_counter_ns()
        elapsed_time_ms = round(
            Float64(end_time - UInt(self.start_time)) / 1e6, 3
        )
        print("Elapsed time:", elapsed_time_ms, "milliseconds")

def main():
    with Timer():
        print("Beginning execution")
        time.sleep(1.0)
        if len(sys.argv()) > 1:
            raise "simulated error"
        time.sleep(1.0)
        print("Ending execution")

Running this example produces output like this:

mojo context_mgr.mojo
Beginning execution
Ending execution
Elapsed time: 2010.0 milliseconds
mojo context_mgr.mojo fail
Beginning execution
Elapsed time: 1002.0 milliseconds
Unhandled exception caught during execution: simulated error

Define a conditional __exit__() method

When creating a context manager, you can implement the __exit__(self) form of the __exit__() method to handle completion of the with statement under all circumstances including errors. However, you have the option of additionally implementing an overloaded version that is invoked instead when an Error occurs in the with code block:

fn __exit__(self, error: Error) raises -> Bool

Given the Error that occurred as an argument, the method can do any of the following:

  • Return True to suppress the error.
  • Return False to re-raise the error.
  • Raise a new error.

The following is an example of a context manager that suppresses only a certain error condition and propagates all others:

conditional_context_mgr.mojo
import time

@fieldwise_init
struct ConditionalTimer(ImplicitlyCopyable):
    var start_time: Int

    fn __init__(out self):
        self.start_time = 0

    fn __enter__(mut self) -> Self:
        self.start_time = Int(time.perf_counter_ns())
        return self

    fn __exit__(mut self):
        end_time = time.perf_counter_ns()
        elapsed_time_ms = round(
            Float64(end_time - UInt(self.start_time)) / 1e6, 3
        )
        print("Elapsed time:", elapsed_time_ms, "milliseconds")

    fn __exit__(mut self, e: Error) raises -> Bool:
        if String(e) == "just a warning":
            print("Suppressing error:", e)
            self.__exit__()
            return True
        else:
            print("Propagating error")
            self.__exit__()
            return False

def flaky_identity(n: Int) -> Int:
    if (n % 4) == 0:
        raise "really bad"
    elif (n % 2) == 0:
        raise "just a warning"
    else:
        return n

def main():
    for i in range(1, 9):
        with ConditionalTimer():
            print("\nBeginning execution")

            print("i =", i)
            time.sleep(0.1)

            if i == 3:
                print("continue executed")
                continue

            j = flaky_identity(i)
            print("j =", j)

            print("Ending execution")

Running this example produces this output:


Beginning execution
i = 1
j = 1
Ending execution
Elapsed time: 105.0 milliseconds

Beginning execution
i = 2
Suppressing error: just a warning
Elapsed time: 106.0 milliseconds

Beginning execution
i = 3
continue executed
Elapsed time: 106.0 milliseconds

Beginning execution
i = 4
Propagating error
Elapsed time: 106.0 milliseconds
Unhandled exception caught during execution: really bad

Handle typed errors in __exit__()

The __exit__(self, error: Error) overload handles only Error values. To handle typed errors, implement a generic __exit__() method with a compile-time error type parameter:

fn __exit__[ErrType: AnyType](self, err: ErrType) -> Bool

This method receives the typed error directly, preserving its full type information. You can use reflection to inspect the error type at compile time. For example, you can:

  • get_type_name[ErrType]() — Get the error type's name as a string.
  • @parameter if conforms_to(ErrType, Writable) — Check if the error implements Writable.
  • trait_downcast[Writable](err) — Access the error through its Writable interface.

The following ResourceGuard example demonstrates this pattern:

from reflection import *

@fieldwise_init
struct ConnectionError(Copyable, Writable):
    var message: String

    fn write_to(self, mut writer: Some[Writer]):
        writer.write("ConnectionError: ", self.message)


struct ResourceGuard(ImplicitlyCopyable):
    var name: String
    var suppress_errors: Bool

    fn __init__(out self, name: String, suppress_errors: Bool = False):
        self.name = name
        self.suppress_errors = suppress_errors

    fn __enter__(self) -> Self:
        print("Acquiring:", self.name)
        return self

    fn __exit__(self):
        print("Releasing:", self.name, "(no error)")

    fn __exit__[ErrType: AnyType](self, err: ErrType) -> Bool:
        comptime type_name = get_type_name[ErrType]()
        print("Releasing:", self.name)
        print("  Error type:", type_name)

        @parameter
        if conforms_to(ErrType, Writable):
            print("  Message:", trait_downcast[Writable](err))

        return self.suppress_errors

When no error occurs, __exit__(self) runs as usual. When a typed error occurs, __exit__[ErrType]() runs instead, giving you access to the error type and its data:

# No error — calls __exit__(self)
with ResourceGuard("database"):
    print("Working...")

# Typed error, suppressed — __exit__[ErrType] returns True
with ResourceGuard("cache", suppress_errors=True):
    use_connection()  # raises ConnectionError
print("Continued after suppressed error")
Acquiring: database
Working...
Releasing: database (no error)

Acquiring: cache
Releasing: cache
  Error type: ConnectionError
  Message: ConnectionError: connection timed out
Continued after suppressed error

Was this page helpful?