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.
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. When enabled you must also setchart.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).
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 in at least one model on the dashboard. - 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
Common pitfalls
- "Sankey data contains a circular flow." The edge list has a cycle. Sankey needs a directed acyclic graph — find the link that points back to an earlier stage and remove or rename it.
- The diagram is empty. Every link was dropped — check for non-positive
values, blank source/target names, or self-loops, all of which are skipped. - Two stages share a node name. If the same label appears as both a category and a status, the flows merge onto one node. Give stages distinct names in the query if that is not intended.
- Clicking a node does nothing. Node clicks emit a pill only inside a dashboard, only when
chart.cross_filteris enabled with across_filter_emit, and only if that field is declared as a parameter in a model on the dashboard. Link clicks never emit. - Labels overlap on a dense diagram. Leave
chart.show_value_labelsoff, or increasechart.height/chart.node_gap.