ggplot-rs
A Rust implementation of ggplot2's Grammar of Graphics, rendering through a
self-contained SVG backend (no dependencies) or, with the default plotters
feature, the plotters SVG/bitmap backend.
Validated against R. Computed layers — binning, density, stacking, QQ/ECDF,
LOESS, and axis-tick placement (extended-Wilkinson) — are checked against
R ggplot2 4.0.3's ggplot_build() output, so a
histogram or a stacked bar comes out where ggplot2 puts it. See
validation/.
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 --features sf --example gallery (the
choropleth needs the sf feature; drop it for the rest).
Publication-ready (ggpubr-style)
Journal palettes, theme_pubr(), GAM smoothing, and statistical annotations —
the last two need the regression / ggpubr features. Regenerate with
cargo run --no-default-features --features regression,ggpubr,plotters --example ggpubr_gallery.
Themes
The same plot under each built-in theme — swap with a single .theme(theme_*()) call.
Quick Start
use *;
use *;
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
Spatial (geom_sf)
Behind the optional sf feature, geom_sf renders simple-features geometry —
points, lines, and (multi)polygons with holes — from a geometry column of
WKT strings. It uses a self-contained WKT parser, so the spatial layer pulls
in no extra dependencies. The fill aesthetic drives a choropleth, and
axes/legends/facets come from the rest of the grammar as usual:
let df = df! ?;
new
.aes
.geom_sf
.scale_fill_viridis_c;
Projections & aspect. Pass a projection to geom_sf and pair it with
coord_sf() (equal-aspect, derived from the data extent) for a shape-correct
map — e.g. a conformal Web Mercator:
use SfProjection;
use GeomSf;
new // lon/lat WKT in `geometry`
.aes
.geom_sf_with
.coord_sf;
Load GeoJSON. The geojson feature reads a FeatureCollection into a
plot-ready frame — geometry → WKT, properties → columns:
use read_geojson_file;
let cols = read_geojson_file?; // Vec<(String, Vec<Value>)>
new.aes.geom_sf;
See examples/spatial.rs (cargo run --features sf --example spatial).
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'slegend.position = c(x, y)). - Axis label rotation:
axis_text_x_angle(deg)/axis_text_y_angle(deg)(R'sguide_axis(angle = ...)). - Label dodging:
axis_text_x_dodge(n)staggers crowded x labels acrossnrows (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) andwith_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:
new
.aes
.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:
# discover columns first, then plot
# aggregate with SQL (reads parquet globs), faceted bars
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.
Maps from the CLI (--spatial, --geom sf). DuckDB's
spatial extension reads
shapefiles, GeoJSON, GeoPackage, FlatGeobuf and more (via GDAL). Pass
--spatial to load it, then ST_AsText(geom) any geometry into a geometry
column and plot it with --geom sf:
ST_Read handles the file format; ST_AsText produces WKT that geom_sf
consumes; --projection mercator and an equal-aspect coord_sf are applied
automatically. Any SQL works, so you can join, filter, or aggregate spatial and
tabular data in the same query before plotting.
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:
# brand.toml — applied on top of the base preset
= "minimal"
= "RdBu"
= [200, 60, 40]
[]
= 22
= [40, 40, 90]
[]
= [248, 246, 240]
[]
= "dashed"
[]
= "inside"
= 0.9
= 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.
In the browser (WASM)
▶ Live demo: https://sipemu.github.io/ggplot-rs/ — DuckDB-Wasm reads Natural Earth countries into a hover-able choropleth, plus a 100k-point scatter via the raster backend.
The crates/ggplot-rs-wasm workspace crate (a cdylib,
not published) exposes a plotters-free renderer to JavaScript via wasm-bindgen.
It compiles to wasm32 and uses the self-contained SvgBackend, so
the bundle is small (~310 KB .wasm, no polars, no fonts — text is <text> the
browser draws). The ggplot-rs library itself is a plain rlib with no
wasm-specific dependencies, so it also builds for wasm32-unknown-unknown /
emscripten targets directly (default-features = false):
import init from "./pkg/ggplot_rs.js";
await ;
document.. = ;
Every mark is a real DOM element, so hover works out of the box — each feature
carries a <title> tooltip (from a label mapping + the fill value), plus CSS
:hover for highlight.
Pair it with DuckDB-Wasm (which loads
the same spatial extension) to read shapefiles/GeoJSON and ST_AsText them to
WKT entirely client-side.
Large N. SVG is one DOM node per mark (great to ~10k–50k). For more, the
canvas feature adds a self-contained RGBA raster backend (render_rgba /
render_png_raster, or the wasm crate's render_scatter_rgba) — it rasterises everything in
pure Rust (text via ab_glyph), so it's fast and wasm-compatible: 500k points
render in a fraction of a second to a bitmap you blit with putImageData. (Or
aggregate in DuckDB — GROUP BY, hex-bins, sampling — and render the summary.)
See web/ for a runnable demo that fetches Natural Earth countries:
The same render is one CLI command (native, identical render_svg_native path):
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!;
new
// Row-oriented
let rows: = vec!;
new
Apache Arrow / DuckDB — feed a RecordBatch straight from a DuckDB query
result, with polars switched off:
# Cargo.toml — no polars in the dependency tree (add "plotters" for render_svg/render_png)
= { = "0.16", = false, = ["arrow"] }
let batch: RecordBatch = /* DuckDB query → Arrow */;
new
polars (optional, enabled by default) — for df! and polars pipelines:
let df = df! ?;
new
Rendering
Without plotters (default-features = false) — the self-contained SVG
backend, also used in the browser; zero rendering dependencies:
let svg: String = plot.render_svg_native_with_size?;
The remaining methods need the plotters feature (on by default). Save to a file (format inferred from the extension — svg, png, jpg, ...):
plot.save?; // 800x600 default
plot.save_with_size?;
plot.ggsave?; // 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: = plot.render_png_with_size?; // 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):
new
.aes
.geom_col
.primary_color // 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;
new.aes.geom_line.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:
new
.aes
.geom_point
.scale_color_gradientn;
Feature Flags
| Feature | Default | Provides |
|---|---|---|
polars |
yes | impl GGData for polars::DataFrame + polars re-export |
plotters |
yes | plotters-backed render_svg, render_png, save, ggsave, ggarrange_png (implies png) |
png |
(yes) | PNG encoding via image (pulled in by plotters and canvas) |
arrow |
no | impl GGData for arrow::RecordBatch (Arrow/DuckDB input) |
regression |
no | stat_quantile/geom_quantile + geom_smooth glm/rlm via anofox-regression |
serde |
no | theme::config::ThemeConfig — a serde-deserialisable partial theme overlay (TOML/JSON) |
sf |
no | geom_sf / coord_sf — render simple-features (WKT) geometry with projections; no extra deps |
geojson |
no | read GeoJSON into a plot-ready frame (spatial::geojson); adds serde_json |
canvas |
no | self-contained RGBA raster backend for large-N (render_rgba/render_png_raster); wasm-ok |
wasm |
no | deprecated alias for sf (the browser bindings moved to the crates/ggplot-rs-wasm crate) |
cli |
no | the ggplot-rs command-line tool (parquet/CSV/DuckDB → SVG/PNG), via clap + bundled DuckDB |
To skip the heavy polars and plotters dependencies (e.g. an Arrow-only service
that renders with render_svg_native*), disable defaults:
= { = "0.16", = false, = ["arrow"] }
With default-features = false, features = ["sf"] the whole dependency tree is
ggplot-rs + indexmap (+ its two dependencies).
Examples
Run any example with:
Dependencies
- indexmap 2 — ordered maps for internal data
- plotters 0.3 — SVG/PNG rendering (
ab_glyphtext backend; no fontconfig) (optional, default) - image 0.24 — in-memory PNG encoding (optional, via
plotters/canvas) - polars 0.46 — DataFrame input (optional, default)
- arrow 53 — Arrow
RecordBatchinput (optional) - clap 4 + duckdb 1 (bundled) — the
clitool (optional)
License
Licensed under either of
- Apache License, Version 2.0 (LICENSE-APACHE or http://www.apache.org/licenses/LICENSE-2.0)
- MIT license (LICENSE-MIT or http://opensource.org/licenses/MIT)
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
Bundled fonts in assets/fonts/ — DejaVu Sans (+ Bold/Oblique), Serif (+ Bold/Italic), and Sans Mono (+ Bold) — give headless rendering for family = "serif"/"monospace" and bold/italic (element_text(face=)) with real glyphs, no fontconfig. DejaVu Sans
is distributed under a permissive, freely-redistributable license (Bitstream Vera
- Arev) — see
assets/fonts/LICENSE-DejaVu.txt.