How to Plot Data from Rust (plotters crate)

Plot data in Rust using the plotters crate by creating a bitmap backend, configuring a chart area, and drawing series.

When numbers need a shape

You've crunched the data. You have a vector of sensor readings, a list of transaction amounts, or a simulation of particle positions. Now you need to show it. In Python, you'd reach for matplotlib. In JavaScript, you'd grab D3 or Chart.js. In Rust, you don't want to pull in a heavy GUI framework just to render a chart to a file. You want something that fits in a CLI tool, a web backend, or a scientific script. You want a plot that generates a file and gets out of the way.

That's what the plotters crate does. It generates static images of your data without requiring a display server, a window manager, or a browser. You feed it numbers, it spits out a PNG, SVG, or PDF. The library is designed for headless environments and integrates cleanly into Rust's type system.

The canvas, the frame, the paint

plotters treats plotting like a construction project. You start with a backend, which is the physical medium. Then you build a drawing area, which is the safe workspace. Next, you erect a chart, which sets up the coordinate system, axes, and labels. Finally, you draw series, which are the actual data points connected by lines, bars, or markers.

Think of the backend as a canvas. The chart builder is the frame and the grid lines you tape onto the canvas. The series are the paint strokes. You can swap the canvas for a poster board or a digital screen, and the frame and paint strokes stay the same. This separation lets you write chart logic once and render to multiple formats without changing your code.

The library enforces this structure through traits. The backend implements the Backend trait. The drawing area implements DrawingArea. The chart implements Chart. Each layer wraps the previous one, adding capabilities while keeping the borrow checker happy.

Minimal example

Here is the smallest complete program that generates a line chart. It creates a PNG file with three data points connected by a red line.

use plotters::prelude::*;

fn main() -> Result<(), Box<dyn std::error::Error>> {
    // Create a PNG backend with 640x480 pixels.
    // The filename is relative to the current working directory.
    let root = BitMapBackend::new("plot.png", (640, 480)).into_drawing_area();
    
    // Fill the background with white.
    // Without this, the image may have transparency artifacts.
    root.fill(&WHITE)?;

    // Build a 2D Cartesian chart.
    // The ranges define the visible coordinate space for x and y.
    let mut chart = ChartBuilder::on(&root)
        .caption("Sample Plot Title", ("sans-serif", 30).into_font())
        .margin(5)
        .x_label_area_size(30)
        .y_label_area_size(30)
        .build_cartesian_2d(0f32..10f32, 0f32..10f32)?;

    // Draw the grid lines and axis labels.
    // This must happen before drawing series.
    chart.configure_mesh().draw()?;

    // Add a line series connecting the data points.
    // The color is a reference to a constant from the prelude.
    chart.draw_series(LineSeries::new(
        vec![(1f32, 2f32), (2f32, 4f32), (3f32, 8f32)],
        &RED,
    ))?;

    Ok(())
}

Add plotters = "0.3" to your Cargo.toml dependencies. The 0.3 series is the current stable release. The code imports plotters::prelude::*, which is the standard convention. The prelude brings in all the types, traits, and color constants you need. Importing individually leads to namespace collisions and verbose code.

How the drawing pipeline works

The pipeline flows from backend to series. BitMapBackend::new opens a file handle and creates a bitmap buffer. into_drawing_area wraps the backend in a trait object that implements DrawingArea. This wrapper is essential. It provides a uniform interface for all backends and handles the borrow checker constraints. You cannot draw directly on a backend. You must go through a drawing area.

ChartBuilder::on takes a reference to the drawing area. The builder pattern lets you configure the chart step by step. caption adds a title. margin sets the padding around the chart. x_label_area_size and y_label_area_size reserve space for axis labels. build_cartesian_2d consumes the builder and returns a Cartesian2d chart. The ranges 0f32..10f32 define the coordinate system. These ranges must match the type of your data. If you use f32 ranges, your data must be f32. If you use f64 ranges, your data must be f64.

configure_mesh sets up the grid. It calculates tick marks and labels based on the ranges. draw renders the grid to the drawing area. draw_series adds a series to the chart. LineSeries::new takes an iterator of (x, y) tuples and a color. The iterator can be a vector, a range, or any type that implements IntoIterator. The color is a reference to a constant like RED, BLUE, or a custom RGB value.

The library uses Result for error handling. Every drawing operation returns a Result. You must propagate errors with ? or handle them explicitly. This ensures that file I/O failures, font errors, and rendering issues are caught early.

Realistic example: Sensor data

Real data is rarely hardcoded. You usually generate it or read it from a file. Here is an example that plots a sine wave with 100 points. It demonstrates range selection, label formatting, and multiple series.

use plotters::prelude::*;
use std::f64::consts::PI;

fn main() -> Result<(), Box<dyn std::error::Error>> {
    // Create a larger PNG for better resolution.
    let root = BitMapBackend::new("sine_wave.png", (800, 600)).into_drawing_area();
    root.fill(&WHITE)?;

    // Generate 100 points for a smooth sine wave.
    // Map integers to floating point coordinates.
    let data: Vec<(f64, f64)> = (0..100)
        .map(|i| {
            let x = i as f64 * 0.1;
            let y = (x * 2.0 * PI).sin();
            (x, y)
        })
        .collect();

    // Build the chart with f64 ranges to match the data type.
    let mut chart = ChartBuilder::on(&root)
        .caption("Sine Wave Simulation", ("sans-serif", 40).into_font())
        .margin(10)
        .x_label_area_size(40)
        .y_label_area_size(40)
        .build_cartesian_2d(0f64..10f64, -1.1f64..1.1f64)?;

    // Configure the mesh with custom label formatters.
    // This controls how tick values appear on the axes.
    chart.configure_mesh()
        .x_label_formatter(&|v| format!("{:.1}", v))
        .y_label_formatter(&|v| format!("{:.2}", v))
        .draw()?;

    // Draw the sine wave as a blue line.
    chart.draw_series(LineSeries::new(
        data.iter().cloned(),
        &Blue,
    ))?;

    // Add a second series for comparison.
    // This draws a cosine wave in red.
    let cos_data: Vec<(f64, f64)> = data.iter()
        .map(|(x, _)| (*x, (*x * 2.0 * PI).cos()))
        .collect();

    chart.draw_series(LineSeries::new(
        cos_data.iter().cloned(),
        &Red,
    ))?;

    Ok(())
}

The ranges 0f64..10f64 and -1.1f64..1.1f64 match the data. The sine wave oscillates between -1 and 1, so the y-range includes a small margin. The label formatters use format! to control decimal places. x_label_formatter shows one decimal place. y_label_formatter shows two. This makes the axes readable without clutter.

The code draws two series. draw_series can be called multiple times. Each call adds a new layer to the chart. The order matters. Series drawn later appear on top of earlier series. This lets you overlay data or highlight specific points.

Pitfalls and compiler errors

plotters is strict about types and ranges. The compiler will catch mistakes early, but the errors can be cryptic if you're not expecting them.

If you mix f32 and f64, the compiler rejects the code with E0308 (mismatched types). The ranges and data must use the same floating point type. If your chart uses f32 ranges, your data must be f32. If you have f64 data, change the ranges to f64. The library does not auto-cast between types.

// This fails with E0308 because the range is f32 but data is f64.
let chart = ChartBuilder::on(&root)
    .build_cartesian_2d(0f32..10f32, 0f32..10f32)?;

let data = vec![(1.0_f64, 2.0_f64)];
chart.draw_series(LineSeries::new(data, &RED))?;

Fix this by aligning the types. Use 0f64..10f64 for the ranges and f64 data. Or cast the data to f32 if precision allows.

Ranges define the visible world. If your data exceeds the ranges, the plot clips silently. Points outside the bounds are not drawn. This is not an error. It's a feature. The chart respects the coordinate system you defined. If you see missing data, check your ranges. Expand them to cover your data.

Fonts can be tricky on headless systems. ("sans-serif", 30).into_font() relies on system fonts. If the font is not available, plotters may fall back to a default or fail. On servers, font libraries might be missing. Check your environment. Install font packages if needed. Or use SVGBackend, which embeds font references and renders more reliably in browsers.

Error handling is mandatory. Every drawing operation returns a Result. If you ignore the result, the compiler warns you. Use ? to propagate errors. Or handle them with match. Never unwrap silently. File I/O can fail. Font loading can fail. Rendering can fail. Handle the errors gracefully.

Convention dictates plotters::prelude::*. The prelude includes all the types and constants you need. Importing individually leads to verbose code and namespace collisions. The community expects the prelude. Use it.

Ranges are boundaries. If your data exceeds them, the plot clips silently. Check your bounds.

Decision: Backends and alternatives

plotters supports multiple backends. Choose the backend based on where the plot will live.

Use BitMapBackend when you need raster images like PNG or JPEG for reports, web assets, or email attachments. Bitmaps are universal and small. They render quickly and display correctly everywhere. This is the default choice for most use cases.

Use SVGBackend when you need scalable vector graphics for documentation, high-resolution displays, or further editing in vector tools. SVGs scale without quality loss. They embed font references and support interactivity in browsers. Use SVG for technical documentation or dashboards.

Use PDFBackend when you are generating multi-page documents or need vector output embedded in PDF workflows. PDFs are standard for printing and archiving. plotters can render charts directly to PDF pages. Use this for batch reports or scientific papers.

Use plotters when you need static chart generation in a Rust application without GUI dependencies. The crate is lightweight and headless. It fits in CLI tools, web servers, and scientific scripts. If you need interactive charts in a browser, reach for a JavaScript library instead. plotters generates files, not live widgets.

Reach for ndarray combined with plotters when you are doing heavy numerical computation and need to plot matrix data directly. ndarray provides efficient multi-dimensional arrays. You can iterate over ndarray data and feed it to plotters series. This combination is powerful for scientific computing.

The backend is your medium. Choose it based on where the plot will live, not how the data looks.

Where to go next