ggplot-rs 0.7.0

A Rust implementation of ggplot2's Grammar of Graphics
Documentation

ggplot-rs

CI Crates.io Documentation codecov License: MIT OR Apache-2.0

A Rust implementation of ggplot2's Grammar of Graphics, rendering through the plotters backend.

No polars required. polars is a convenient — and fully optional — input adapter. The core pipeline runs on its own internal DataFrame, so you can plot straight from plain Rust vectors, or from Apache Arrow RecordBatches produced by DuckDB — with polars switched off entirely. See Data Input and Feature Flags.

Gallery

Every image below is produced by examples/gallery.rs — regenerate them all with cargo run --example gallery.

Themes

The same plot under each built-in theme — swap with a single .theme(theme_*()) call.

Quick Start

use ggplot_rs::prelude::*;
use polars::prelude::*;

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let df = df! {
        "sepal_length" => [5.1, 4.9, 4.7, 7.0, 6.4],
        "sepal_width"  => [3.5, 3.0, 3.2, 3.2, 3.2],
        "species"      => ["setosa", "setosa", "setosa", "versicolor", "versicolor"],
    }?;

    GGPlot::new(df)
        .aes(Aes::new().x("sepal_length").y("sepal_width").color("species"))
        .geom_point()
        .save("scatter.svg")?;

    Ok(())
}

Features

Geoms

geom_point, geom_line, geom_bar, geom_col, geom_histogram, geom_boxplot, geom_violin, geom_smooth, geom_density, geom_area, geom_ribbon, geom_errorbar, geom_segment, geom_rug, geom_text, geom_label, geom_tile, geom_raster, geom_bin2d, geom_hex, geom_contour, geom_contour_filled, geom_path, geom_step, geom_hline, geom_vline, geom_abline, and more (40+)

Stats

StatIdentity, StatCount, StatBin, StatBoxplot, StatSmooth (Lm + Loess), StatDensity, StatLoess, StatSummary, StatEcdf, StatFunction, StatEllipse, StatContour, StatBin2d, StatBinHex, StatSum, StatYDensity, StatQQ, StatSummary2d, StatQuantile (feature regression), and more

Scales

  • Continuous: linear, log10, log2, ln, sqrt, reverse, logit, probit, pseudo-log, reciprocal, exp, and Box–Cox transforms
  • Discrete: automatic categorical mapping
  • Color: discrete palettes (Viridis, Brewer Set1/Dark2, etc.), continuous gradients, diverging gradient2, binned/stepped scales (scale_color_steps/fermenter), manual color assignment
  • Shape & Linetype: discrete mapping for point shapes and line styles

Coordinates

coord_cartesian, coord_flip, coord_fixed, coord_polar, coord_trans

Faceting

facet_wrap and facet_grid with free/fixed scales, proportional panel sizing (space = "free" via facet_grid_space), and multi-variable columns (facet_grid_multi, R's rows ~ b + c). Computed stats (density/histogram) are estimated per panel.

Themes

theme_gray, theme_bw, theme_classic, theme_minimal, theme_dark, theme_light, theme_linedraw, theme_void — plus full customization via ElementText, ElementLine, ElementRect

Annotations

annotate_text, annotate_rect, annotate_segment

Guides & axes

  • Legend inside the panel at panel-relative coords: legend_position_inside(x, y) (R's legend.position = c(x, y)).
  • Axis label rotation: axis_text_x_angle(deg) / axis_text_y_angle(deg) (R's guide_axis(angle = ...)).
  • Label dodging: axis_text_x_dodge(n) staggers crowded x labels across n rows (guide_axis(n.dodge)).
  • Corner tag: tag("A") for figure-panel labels (labs(tag)).
  • Axis position & expansion: ScaleContinuous::with_position_opposite() (x-axis on top / y on the right) and with_expand_sides(...) for per-side expansion.

Call theme-related builders after any theme_*() preset.

Computed aesthetics

An aesthetic can map an expression over columns, not just a bare column name:

GGPlot::new(data)
    .aes(Aes::new().x("log10(gdp)").y("pop / 1e6").color("deaths / cases"))
    .geom_point();

Supports + - * / % ^, parentheses, and ln/log/log10/log2/sqrt/exp/abs/sin/cos/tan/floor/ceil/round/sign. A plain column name is used directly (so existing mappings are unchanged); anything else is parsed and evaluated per row. after_scale_fill_from_color(l) / after_scale_color_from_fill(l) derive one color aesthetic from another's mapped color, lightness-adjusted (after_scale); Aes::stage(aes, start, after_stat) maps an aesthetic at two pipeline stages. The same expressions work in after_stat mappings, plus aggregate functions (sum, mean, max, min, count, median, prod) that reduce over all rows — e.g. .after_stat_y("count / sum(count)") for proportion histograms.

Command-line tool

A ggplot-rs CLI (behind the cli feature) plots parquet/CSV files or DuckDB SQL straight from the shell — DuckDB is the query engine:

cargo install ggplot-rs --features cli

# discover columns first, then plot
ggplot-rs --parquet sales.parquet --describe
ggplot-rs --parquet sales.parquet --x month --y revenue --geom line -o rev.png

# aggregate with SQL (reads parquet globs), faceted bars
ggplot-rs --sql "SELECT region, sum(qty) q FROM 'orders/*.parquet' GROUP BY 1" \
  --x region --y q --geom col --facet-wrap region --theme minimal -o orders.svg

Flags: --x/--y/--color/--fill/--size/--shape/--group, --geom, --facet-wrap/--facet-grid, --log-x/--log-y/--flip, --title/--subtitle/--xlab/--ylab/--caption, -o FILE/--stdout, --width/--height. Run --describe to list a source's columns and types.

Theming from the CLI: --theme <preset> (gray/bw/minimal/…), --palette <name> (Set1/Dark2/viridis/RdBu/…), --primary "r,g,b" (brand color), and --theme-config <file> — a TOML/JSON file of element overrides for full custom theming:

ggplot-rs --parquet d.parquet --x a --y b --color g --palette Dark2 --primary "26,153,136" -o p.png
ggplot-rs --parquet d.parquet --x a --y b --color g --theme-config brand.toml -o p.png
# brand.toml — applied on top of the base preset
base = "minimal"
palette = "RdBu"
primary = [200, 60, 40]
[title]
size = 22
color = [40, 40, 90]
[panel_background]
fill = [248, 246, 240]
[legend]
position = "inside"
x = 0.9
y = 0.9

AI-ready: the repo ships a Claude Code skill at .claude/skills/plot-data/ that teaches an agent the describe-then-map-then-render workflow, so "plot this parquet" just works.

Data Input

GGPlot::new accepts anything implementing the GGData trait. Nothing here requires polars — pick whichever source fits your stack.

Plain Rust — zero optional dependencies:

// Column-oriented
let cols: Vec<(String, Vec<Value>)> = vec![
    ("x".into(), vec![Value::Float(1.0), Value::Float(2.0), Value::Float(3.0)]),
    ("y".into(), vec![Value::Float(4.0), Value::Float(5.0), Value::Float(6.0)]),
];
GGPlot::new(cols)

// Row-oriented
let rows: Vec<HashMap<String, Value>> = vec![/* ... */];
GGPlot::new(rows)

Apache Arrow / DuckDB — feed a RecordBatch straight from a DuckDB query result, with polars switched off:

# Cargo.toml — no polars in the dependency tree
ggplot-rs = { version = "0.6", default-features = false, features = ["arrow"] }
let batch: arrow::record_batch::RecordBatch = /* DuckDB query → Arrow */;
GGPlot::new(batch)

polars (optional, enabled by default) — for df! and polars pipelines:

let df = df! {
    "x" => [1.0, 2.0, 3.0],
    "y" => [4.0, 5.0, 6.0],
}?;
GGPlot::new(df)

Rendering

Save to a file (format inferred from the extension — svg, png, jpg, ...):

plot.save("out.svg")?;              // 800x600 default
plot.save_with_size("out.png", 1200, 800)?;
plot.ggsave("out.png", 6.0, 4.0, 150.0)?; // width_in, height_in, dpi

Or render in memory — no temp files — which is what you want when serving charts from a web/MCP service:

let svg: String   = plot.clone().render_svg()?;          // or render_svg_with_size(w, h)
let png: Vec<u8>  = plot.render_png_with_size(400, 300)?; // fully-encoded PNG bytes

Headless / no system fonts. Rendering uses plotters' ab_glyph text backend with a bundled font (DejaVu Sans), not font-kit/fontconfig — so text renders deterministically in a minimal container with no system fonts installed. Nothing to configure; there is no dependency on the host's font stack.

Theming & brand color

Everything about a theme is set at runtime, so one render process can serve many tenants' brands without touching chart code.

Inject a brand/primary color — it becomes the default for any single-series geom that has no color/fill aesthetic mapped (an explicit mapping always wins):

GGPlot::new(data)
    .aes(Aes::new().x("day").y("count"))
    .geom_col()
    .primary_color((26, 153, 136)) // DataZoo teal — no per-chart color code
    .render_svg()?;

Build a whole Theme at runtime and compose the brand into it:

let theme = theme_minimal().with_primary((26, 153, 136));
GGPlot::new(data).aes(/**/).geom_line().theme(theme);

Supply an arbitrary sequential ramp (e.g. a green→red risk score) instead of the built-in viridis/brewer scales — pass explicit (offset, color) stops:

GGPlot::new(data)
    .aes(Aes::new().x("x").y("y").color("risk"))
    .geom_point()
    .scale_color_gradientn(vec![
        (0.0, RGBAColor::new(0, 160, 80)),   // low  = green
        (0.5, RGBAColor::new(240, 200, 0)),  // mid  = amber
        (1.0, RGBAColor::new(200, 40, 40)),  // high = red
    ]);

Feature Flags

Feature Default Provides
polars yes impl GGData for polars::DataFrame + polars re-export
arrow no impl GGData for arrow::RecordBatch (Arrow/DuckDB input)
regression no stat_quantile/geom_quantile + geom_smooth glm/rlm via anofox-regression
cli no the ggplot-rs command-line tool (parquet/CSV/DuckDB → SVG/PNG), via clap + bundled DuckDB

To skip the heavy polars dependency (e.g. an Arrow-only service), disable defaults:

ggplot-rs = { version = "0.6", default-features = false, features = ["arrow"] }

Examples

Run any example with:

cargo run --example scatter
cargo run --example histogram
cargo run --example bar_chart
cargo run --example continuous_color
cargo run --example density
cargo run --example faceted
cargo run --example loess_smooth
cargo run --example annotations
cargo run --example coord_flip
cargo run --example log_scale
cargo run --example color_palettes
cargo run --example gallery            # regenerates the gallery above
cargo run --example supplier_leadtime  # polars-free; runs with --no-default-features

Dependencies

  • plotters 0.3 — SVG/PNG rendering (ab_glyph text backend; no fontconfig)
  • image 0.24 — in-memory PNG encoding
  • indexmap 2 — ordered maps for internal data
  • rand 0.8 — jitter positioning
  • polars 0.46 — DataFrame input (optional, default)
  • arrow 53 — Arrow RecordBatch input (optional)
  • clap 4 + duckdb 1 (bundled) — the cli tool (optional)

License

Licensed under either of

at your option.

Unless you explicitly state otherwise, any contribution intentionally submitted for inclusion in the work by you, as defined in the Apache-2.0 license, shall be dual licensed as above, without any additional terms or conditions.

Bundled font

assets/fonts/DejaVuSans.ttf is bundled for headless text rendering. DejaVu Sans is distributed under a permissive, freely-redistributable license (Bitstream Vera