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.
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.
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 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 with chart.label.chart.height — pixel height of the viz container.chart.cross_filter — boolean. When enabled you must also set chart.cross_filter_emit.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).
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.
Sankey participates in cross-filtering on node clicks; clicks on the links between nodes are ignored. Inside a dashboard:
chart.cross_filter_emit (source or target) carrying the clicked node's name, provided that field is declared as a parameter in at least one model on the dashboard.chart.cross_filter: false to suppress click emission while still consuming pills.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
values, blank source/target names, or self-loops, all of which are skipped.chart.cross_filter is enabled with a cross_filter_emit, and only if that field is declared as a parameter in a model on the dashboard. Link clicks never emit.chart.show_value_labels off, or increase chart.height / chart.node_gap.