How to Use the miette Crate for Beautiful Error Reports

Add miette to Cargo.toml and derive Diagnostic on your error types to get beautiful, contextual error reports.

When errors need to speak human

You're building a configuration parser for a CLI tool. The user types myapp config.yaml. The file is missing. Rust panics or returns an std::io::Error. The output looks like Error: Os { code: 2, kind: NotFound, message: "No such file or directory" }. The user stares at the terminal. They don't know if they typed the wrong name, if the file is hidden, or if they're in the wrong directory. They close the app and never come back.

Errors are the interface between your code and the person trying to use it. Standard Rust errors are great for the compiler and great for logging. They are terrible for humans. The miette crate fixes this. It turns cryptic error codes into structured reports with codes, help text, and even source code snippets pointing to the exact line that failed.

The diagnosis, not just the symptom

Standard Rust errors give you the symptom. miette gives you the diagnosis and the prescription.

Think of a standard error like a check engine light. It tells you the car is unhappy. It doesn't tell you the oil is low, nor does it tell you which cap to open. miette turns that light into a dashboard message: "Oil pressure low. Check dipstick. Add 5W-30." It adds metadata that the rendering engine uses to build a box around the error, highlight the relevant code, and suggest a fix.

The crate works by extending the standard error system. You define your error types as usual, but you add miette attributes to attach metadata. When the error bubbles up to the top of your program, miette intercepts it and draws the report.

Minimal example

Add miette and thiserror to your dependencies. miette relies on thiserror for the base error implementation.

[dependencies]
miette = "7"
thiserror = "1"

Define an error struct and derive Diagnostic.

use miette::{Diagnostic, Report};
use thiserror::Error;

/// Represents a missing file error with user-facing help.
#[derive(Error, Diagnostic, Debug)]
#[error("File not found")]
#[diagnostic(code(my_app::file_not_found))]
struct FileNotFound {
    #[help]
    help: String,
}

fn main() -> Result<(), Report> {
    // Return the error wrapped in Report.
    // Report is miette's boxed error type, similar to Box<dyn Error>.
    Err(FileNotFound {
        help: "Check the path and try again.".to_string(),
    }.into())
}

Run this and you get a formatted box:

Error: File not found
  --> main.rs:12:5
   |
12 |     Err(FileNotFound {
   |     ^^^^^^^^^^^^^^^^ File not found

  help: Check the path and try again.

The output includes the error message, the diagnostic code, a location hint, and the help text. The box makes the error stand out in a wall of terminal text.

Return a Report, or you get plain text. If you return Result<(), FileNotFound> from main, Rust prints the Display implementation. The box never appears. miette needs the Report wrapper to trigger the graphical handler.

How miette renders the report

The derive macros do the heavy lifting. thiserror::Error implements std::error::Error. This keeps your type compatible with the rest of the Rust ecosystem. miette::Diagnostic implements miette::Diagnostic, which adds the metadata layer.

The #[diagnostic(code(...))] attribute sets a unique identifier. Use this for documentation. Users can search for my_app::file_not_found in your docs. The #[help] attribute marks a field as the suggestion. miette renders this in a distinct section.

Report acts as the transport. It's a smart pointer to an error. When you print a Report, miette checks if the inner error implements Diagnostic. If it does, miette extracts the code, help, severity, and labels, then passes them to the handler. The handler draws the box.

Convention aside: The Rust community treats thiserror as the standard for defining error types and miette as the standard for rendering them in applications. You'll almost always see them paired. Derive both. Use thiserror attributes like #[error] and #[source], and miette attributes like #[diagnostic] and #[help].

Adding source code context

Real errors need context. miette can display source code and highlight the problematic span. This is invaluable for parsers, compilers, and configuration loaders.

use miette::{Diagnostic, SourceCode, SourceSpan};
use thiserror::Error;

/// Error for invalid configuration syntax.
#[derive(Error, Diagnostic, Debug)]
#[error("Invalid configuration")]
#[diagnostic(code(config::syntax_error))]
struct ConfigError {
    #[source_code]
    source: String,
    #[label("this line is broken")]
    span: SourceSpan,
    #[help]
    help: String,
}

fn main() -> Result<(), miette::Report> {
    let source = "name = alice\nage = 30\n".to_string();
    
    // SourceSpan takes a byte offset and a length.
    // "alice" starts at byte 11 and is 5 bytes long.
    let span = SourceSpan::new(11.into(), 5.into());

    Err(ConfigError {
        source,
        span,
        help: "Wrap string values in quotes.".to_string(),
    })
}

The output shows the source code with a caret pointing to the error:

Error: Invalid configuration
  --> main.rs:18:11
   |
18 |     let source = "name = alice
   |                   ^^^^^^^^^^^^ this line is broken

  help: Wrap string values in quotes.

The #[source_code] attribute tells miette which field holds the text. String, &str, and Vec<u8> implement the SourceCode trait out of the box. The #[label] attribute attaches text to a SourceSpan. You can have multiple labels on one error. miette renders them all.

Byte offsets, not char offsets. SourceSpan expects byte indices. If your parser tracks characters, converting to bytes requires char_indices. Using the wrong index highlights the wrong text or panics. Measure twice, highlight once.

Error chains and causes

Errors often have causes. A file read fails because of an I/O error. miette supports error chains via #[source].

use miette::{Diagnostic, Report};
use thiserror::Error;
use std::io;

/// Wrapper for I/O errors with custom context.
#[derive(Error, Diagnostic, Debug)]
#[error("Failed to read config")]
#[diagnostic(code(config::read_failed))]
struct ReadConfigError {
    #[source]
    source: io::Error,
    #[help]
    help: String,
}

fn main() -> Result<(), Report> {
    let io_err = io::Error::new(io::ErrorKind::NotFound, "config.yaml");
    Err(ReadConfigError {
        source: io_err,
        help: "Ensure the file exists and permissions are correct.".to_string(),
    }.into())
}

miette renders the chain. The top-level error gets the box. The source error appears below it. This preserves the full context without losing the formatting.

The #[source] attribute comes from thiserror. miette respects it. You can chain Diagnostic errors together. The renderer walks the chain and displays each link.

Pitfalls and gotchas

Watch out for the main signature. If you return Result<(), ConfigError>, miette never sees the error. Rust calls Display and prints text. You must return miette::Result or Result<(), Report>. The compiler won't stop you. The user will see garbage. Convert early.

Another trap is SourceSpan byte math. Rust strings are UTF-8. A character can be multiple bytes. If you calculate spans based on character counts, the highlights drift. Use byte_offset from your lexer or parser. If you only have char indices, map them to bytes before creating the span.

Environment variables control the output. MIETTE_GRAPHICAL=0 forces text mode. MIETTE_FORCE_GRAPHICAL=1 forces graphical mode. Users might set these. Respect them. Your app should work in both modes.

Convention aside: Keep unsafe out of error handling. Errors are about safety and correctness. If you need unsafe to construct an error, you're doing something wrong. Stick to safe abstractions.

Choosing your error strategy

Use miette when you're building a CLI tool, a compiler, or a parser where the end user reads the error message and needs actionable help.

Use thiserror when you're writing a library crate and need to define error types that downstream code can match on or convert.

Use anyhow when you're writing application logic and want to chain errors quickly without defining a custom enum for every failure mode.

Use eyre when you need anyhow-style convenience but also want backtraces and context chains in the output.

Use Box<dyn std::error::Error> when you're wrapping third-party errors and don't need to inspect the error type later.

Libraries define types. Applications format reports. Keep that boundary sharp.

Where to go next