When to use sankey
Sankey is the right choice for flow and attribution questions — how volume moves from one set of categories to another and where it branches or merges: traffic source → product category → order status, budget → team → outcome, stage-to-stage movement. Each row of the query is one link: an origin node, a destination node, and the magnitude flowing between them.
Use funnel instead when the process is a single ordered cascade with no branching. Use bar for a plain categorical comparison, or heatmap for a two-dimensional intensity grid.
Mapping
Sankey takes an edge list — one row per link.
mapping.source— required. Field holding the link's origin node name.mapping.target— required. Field holding the link's destination node name.mapping.value— required. Numeric field for the link's magnitude (the ribbon width).
mapping:
source: source
target: target
value: order_count
Nodes are inferred from the union of the source and target values — you do not declare them. Duplicate (source, target) pairs are summed, self-loops (source == target) are dropped, and non-positive values are ignored.
Shaping the data: one row = one link
A sankey usually spans several stages, but the input is a flat edge list. Build it in the model by producing one query per adjacent stage pair and composing them with UNION ALL — the pivot belongs in the model, where it is reviewable, not in the chart. For a source built from raw SQL:
WITH base AS (
SELECT
u.traffic_source AS traffic_source,
p.category AS category,
oi.status AS order_status
FROM `bigquery-public-data.thelook_ecommerce.order_items` AS oi
JOIN `bigquery-public-data.thelook_ecommerce.users` AS u ON oi.user_id = u.id
JOIN `bigquery-public-data.thelook_ecommerce.products` AS p ON oi.product_id = p.id
)
SELECT traffic_source AS source, category AS target, COUNT(*) AS order_count
FROM base GROUP BY 1, 2
UNION ALL
SELECT category AS source, order_status AS target, COUNT(*) AS order_count
FROM base GROUP BY 1, 2
Each additional stage adds one more SELECT … UNION ALL block. The result has exactly the three columns the mapping expects: source, target, order_count.
The SQL lives inside the Malloy model the viz's query references — the complete file wraps it in a raw-SQL source:
# models/traffic_attribution.malloy
##! experimental.parameters
source: traffic_attribution() is ecommerce.sql("""
-- the edge-list SQL above, verbatim
""") extend {
view: edges is { select: * }
}
chart shortcuts
The chart block is typed and closed.
chart.orientation—"horizontal"(default) or"vertical". Direction the flow travels.chart.node_align—"justify"(default),"left", or"right". How nodes are aligned across the diagram.chart.node_width— pixel width of each node bar.chart.node_gap— pixel gap between nodes in the same column.chart.layout_iterations— number of placement iterations (higher = more settled layout).chart.draggable— boolean; let the viewer drag nodes to rearrange.chart.show_value_labels— boolean, default off. Turns node labels on. A sankey labels every node, so leave it off unless the diagram is small. Style withchart.label.chart.height— pixel height of the viz container.chart.cross_filter— boolean, defaulttrue. Whenever it is not explicitlyfalse, the schema requireschart.cross_filter_emitalongside anychartblock.chart.cross_filter_emit—"source"or"target". Which node role a click emits as a filter.
Pass-through style blocks: chart.label (node label styling), chart.line_style (link color, opacity, curveness), and chart.tooltip.
Two things sankey deliberately does not have: no legend (a single flow series has nothing to toggle) and no axis blocks (a sankey has no axes).
Links need a direction: no circular flow
A sankey must be a directed acyclic graph — flow moves forward through the stages and never loops back. If the data contains a cycle (for example A → B, B → C, C → A), the diagram cannot be laid out and the viz shows a clear message: "Sankey data contains a circular flow." That is a signal about the data, not an empty result — trace the loop in the query (usually a stage whose values reappear as an earlier stage's node names) and break it.
Cross-filter behavior
Sankey participates in cross-filtering on node clicks; clicks on the links between nodes are ignored. Inside a dashboard:
- Emits — clicking a node adds a pill for the field named by
chart.cross_filter_emit(sourceortarget) carrying the clicked node's name, provided that field is declared as a parameter on a source signature in this viz's own model file. - Consumes — pills and dashboard filters set elsewhere become parameters on the next run; the sankey's query re-runs and the flows recompute.
- Opt-out — set
chart.cross_filter: falseto suppress click emission while still consuming pills.
Worked example
Traffic attribution across two stages, anchored on the public thelook_ecommerce dataset:
id: traffic_attribution_sankey
title: Traffic Attribution Flow
query: "models/traffic_attribution.malloy::edges"
type: sankey
mapping:
source: source
target: target
value: order_count
chart:
height: 420
orientation: horizontal
node_align: justify
show_value_labels: true
cross_filter: true
cross_filter_emit: target
format:
order_count: "#,##0"
published: true
A read-only diagram (no cross-filter), laid out vertically:
chart:
height: 320
orientation: vertical
cross_filter: false
Design notes
- Flows form a directed acyclic graph — shape the query so no link points back to an earlier stage.
- A link renders when its
valueis positive, both node names are non-blank, and source ≠ target. - Node names merge across stages: the same label appearing as a category and as a status becomes one node. Give stages distinct names in the query unless the merge is intended.
- On dense diagrams, leave
chart.show_value_labelsoff, or increasechart.height/chart.node_gap.