Charts

svg_charts

A DuckDB extension that draws charts, so a chart is just a column of a query result. It returns a complete <svg> as VARCHAR: paste it into a page, or serve it as image/svg+xml. Nothing else is needed at render time — no JavaScript, no web fonts, no requests — and the same extension runs on the server and in the browser under duckdb-wasm.

WITH monthly_sales(month, sales) AS (
  VALUES ('Jan', 120), ('Feb', 148), ('Mar', 131),
         ('Apr', 175), ('May', 203), ('Jun', 188)
)
SELECT svg_line_chart(month, sales, {'background_color': '#ffffff'})
FROM monthly_sales;
line chart

Every chart on this page is the output of the SQL above it, drawn by the extension when this README was generated. They pass 'background_color' only because they are embedded as standalone images here — leave it out and a chart is transparent and takes its colors from the page around it.

Loading it

The extension is unsigned, so DuckDB needs allow_unsigned_extensions. That is a startup-only setting — you cannot turn it on with SET once a connection is open.

how you connect what to set
CLI duckdb -unsigned
Go sql.Open("duckdb", ":memory:?allow_unsigned_extensions=true")
duckdb-wasm db.open({ allowUnsignedExtensions: true }), between instantiate() and connect()

Then, once per database:

INSTALL httpfs; LOAD httpfs;
INSTALL svg_charts FROM 'https://duckweb.jamesst.one';
LOAD svg_charts;

httpfs is what fetches from a custom repository; DuckDB installs it from its own repository the first time.

From a checkout of this repo you can skip the setup: nix develop puts a matching DuckDB CLI on PATH with DUCKDB_EXTENSION_REPO already pointing at a repository that carries the extension, and nix run .#duckdb opens a shell with it loaded alongside duckweb and httpfs.

Drawing one series

svg_line_chart(x, y[, options]), svg_bar_chart(...) and svg_area_chart(...) are aggregates, so they collapse a group of rows into one chart the way sum() collapses it into one number. That is the line chart above; the same data as bars:

WITH monthly_sales(month, sales) AS (
  VALUES ('Jan', 120), ('Feb', 148), ('Mar', 131),
         ('Apr', 175), ('May', 203), ('Jun', 188)
)
SELECT svg_bar_chart(month, sales, {'background_color': '#ffffff'})
FROM monthly_sales;
bar chart

and as an area:

WITH monthly_sales(month, sales) AS (
  VALUES ('Jan', 120), ('Feb', 148), ('Mar', 131),
         ('Apr', 175), ('May', 203), ('Jun', 188)
)
SELECT svg_area_chart(month, sales, {'background_color': '#ffffff'})
FROM monthly_sales;
area chart

x can be a date, timestamp, boolean, number or string — it becomes the axis label, formatted for you. Because these are aggregates, GROUP BY gives you one chart per group:

SELECT regionid, svg_line_chart(ts, avg_rrp) AS chart
FROM prices_monthly
GROUP BY regionid;

Drawing several series

svg_chart(rows[, options]) takes a LIST of STRUCTs, one struct per point, which is what DuckDB’s built-in list() aggregate produces:

WITH readings(hour, nsw, qld) AS (
  VALUES (0, 82.0, 71.5), (4, 64.2, 60.1), (8, 118.7, 96.4),
         (12, 141.3, 130.8), (16, 175.6, 158.2), (20, 121.9, 112.7)
)
SELECT svg_chart(list({'hour': hour, 'nsw': nsw, 'qld': qld} ORDER BY hour),
                 {'background_color': '#ffffff'})
FROM readings;
multi series chart

The first struct field is the x axis unless options.x names another one. Every remaining numeric field becomes a series, named after the field — those names are what appear in the tooltip and what options.series entries match on.

Values that are NULL or infinite leave a gap in the line and show as - in the tooltip.

Getting the points in order

Points are plotted in the order they arrive, and a plain aggregate over a parallel scan receives them in arbitrary order — a line chart of unsorted input comes out scrambled. Sort the input explicitly:

-- the reliable spelling: list() does the sorting
SELECT regionid, svg_chart(list({'x': ts, 'y': avg_rrp} ORDER BY ts))
FROM prices_monthly GROUP BY regionid;

svg_line_chart(ts, y ORDER BY ts) looks like the obvious spelling and does not work — it renders a single point. DuckDB’s sorted-aggregate path feeds the C API a state vector this extension cannot read correctly, and it cannot detect the case either. Use the svg_chart(list(... ORDER BY ...)) form, or sort in a subquery before the aggregate sees the rows.

Options

Options are a DuckDB STRUCT, not a JSON string, so DuckDB’s own parser checks them and you get type errors at bind time. Every field is optional, and keys are matched ignoring case and underscores (aspect_ratio and aspectRatio both work).

key type default what it does
type 'line' \| 'bar' \| 'area' 'line' shape used for series that do not override it
x column name first struct field which field is the x axis (svg_chart only)
aspect_ratio number 2.0 viewBox is 100 * ratio wide by 100 tall
padding number 3.0 margin inside the viewBox, in those same units
background_color color none (transparent) fill behind the whole chart
text_color color 'currentColor' label color; inherits the surrounding page by default
stack_offset 'none' \| 'expand' 'none' 'expand' normalises each column of stacked series to 100%
grid boolean true horizontal grid lines
tooltip 'column' or false 'column' pure-CSS hover tooltip listing every series at that x
x_axis boolean true false hides the axis and reclaims the space
x_axis_height number 8.0 space reserved below the plot
x_axis_labels boolean true false keeps the ticks, drops the text
y_axis boolean true false hides the axis and reclaims the space
y_axis_width number 12.0 space reserved left of the plot
y_axis_ticks number 5 tick count, clamped to 2–20

Per-series options

series is a list of structs. Each needs a key matching a field name in the row struct; entries naming a field that is not there are ignored. Anything left out falls back to the chart-level default.

key type default what it does
key column name required which series this entry configures
type 'line' \| 'bar' \| 'area' chart type shape for this series alone
stroke color palette color for its position line / outline color
fill color palette color for its position area and bar fill
stack_id string none series sharing an id stack on each other
style 'solid' \| 'dashed' 'solid' line dash pattern
border_radius number 0.0 rounds bar corners
smooth boolean true false for straight segments instead of a spline

Putting it together — a dark chart with one dashed series:

WITH quarters(quarter, revenue, cost) AS (
  VALUES ('Q1', 4.2, 3.1), ('Q2', 5.1, 3.4), ('Q3', 4.8, 3.9), ('Q4', 6.3, 4.1)
)
SELECT svg_chart(list({'quarter': quarter, 'revenue': revenue, 'cost': cost} ORDER BY quarter), {
  'aspect_ratio': 2.5,
  'background_color': '#101010',
  'text_color': '#eee',
  'y_axis_ticks': 4,
  'series': [
    {'key': 'revenue', 'stroke': '#4e79a7'},
    {'key': 'cost', 'stroke': '#f28e2b', 'style': 'dashed'}
  ]
}) FROM quarters;
styled chart

Series with no explicit color cycle through an eight-color palette, so a bare svg_chart(rows) is already legible.

Stacking

Series that share a stack_id sit on top of each other, and 'stack_offset': 'expand' turns that stack into shares of 100%:

WITH traffic(n, day, direct, search, social) AS (
  VALUES (1, 'Mon', 320, 540, 120), (2, 'Tue', 300, 610, 140),
         (3, 'Wed', 355, 580, 190), (4, 'Thu', 340, 700, 160),
         (5, 'Fri', 410, 520, 260)
)
SELECT svg_chart(
  list({'day': day, 'direct': direct, 'search': search, 'social': social} ORDER BY n),
  {
    'type': 'area',
    'background_color': '#ffffff',
    'stack_offset': 'expand',
    'series': [
      {'key': 'direct', 'stack_id': 'sources'},
      {'key': 'search', 'stack_id': 'sources'},
      {'key': 'social', 'stack_id': 'sources'}
    ]
  }) FROM traffic;
stacked chart

Using the output

The result is a self-contained <svg width="100%"> with a viewBox, so it scales to whatever box you drop it in and the aspect ratio decides its shape. Text defaults to currentColor and the background to transparent, which means an unstyled chart inherits your page’s colors, dark mode included. background_color is emitted as CSS on the root <svg>, which browsers honor even for an <img>, though plain rasterizers like librsvg ignore it.

Tooltips are CSS :hover rules inside the SVG, so they survive being served as a bare .svg file — but not when the SVG is used as an <img>, which is how the charts on this page are embedded.

Two things worth knowing when you size a chart:

Version

The extension declares C extension API v1.5.6 and loads into any DuckDB that offers it; v2.0.0 is what it is built and tested against. A mismatch fails loudly at LOAD rather than misbehaving later.

How it is built

The chart engine is a Rust port of isomorphic-svg-charts: SVG built as strings, no DOM, no I/O, no dependencies. The wasm build is about 96 KB raw and 37 KB brotli, which matters because the browser downloads it on every cold load.

Working on the extension itself? svg-core/ holds the engine (no DuckDB dependency, no_std + alloc, cargo test) and src/ the DuckDB C API glue. This README is generated: edit README.template.md or docs/examples/*.sql and run nix run .#svg-charts-readme, which rebuilds the extension and redraws every chart with it.