Docs / Build Workflow

Visualization — sankey

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 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).

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 (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.
  • 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: false to 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_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.
  • Labels overlap on a dense diagram. Leave chart.show_value_labels off, or increase chart.height / chart.node_gap.