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.

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 with chart.label.
  • chart.height — pixel height of the viz container.
  • chart.cross_filter — boolean, default true. Whenever it is not explicitly false, the schema requires chart.cross_filter_emit alongside any chart block.
  • 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 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: 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

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 value is 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_labels off, or increase chart.height / chart.node_gap.