# Sankey diagrams

> Page: https://docs.plotwork.dev/sankey-diagrams. Index of every page: https://docs.plotwork.dev/llms.txt

A sankey diagram draws flows between stages as ribbons as thick as the amount that moves. Use it to show where traffic, data, or cost goes: requests through a platform, a cloud bill by service and team, a pipeline that narrows as records drop out, or the outcomes of a request. It is for anyone, human or AI agent, who writes diagram source.

## TL;DR

| Question | Answer |
|---|---|
| What is it for? | Where a total goes. A ribbon is as thick as the amount it carries, and a card is as tall as the amount that passes through it. |
| How do I start one? | Write `sankey "Title"`, then one line for each flow: `"Edge" -> "API gateway" : 12000`. A node exists once a flow names it, so it has no id. |
| How is an amount written? | Plain digits right after the colon, above zero, with no sign, comma, or unit. Say what it counts once, on the header, with `unit="req/s"`. |
| How do I give a node an icon or a tone? | A `node "API gateway" icon=lucide:server tone=blue` line, before or after the flows that name it. Without one, a node takes the next tone of a fixed rotation. |
| What is drawn? | Rounded cards joined by smooth ribbons in the tone of the node they leave, fading to the tone of the node they reach. The widest ribbons carry their amount in a pill, and a line under the picture gives the unit and the total. |
| What if the flow does not balance? | A node whose flow in and out differ gets a dashed wedge for the difference, and lint warns with `W200`. |
| What if flows go round in a loop? | The source is refused with `E100`, which names the loop. A sankey only goes forward. |
| What are the limits? | 60 nodes, 150 flows, and 12 columns. Past one, the source is refused with `E140`. More than 8 columns, 40 nodes, or 100 flows draws a warning. |
| Does Mermaid convert? | Yes. `sankey-beta` and `sankey` become a Plotwork sankey. See [From Mermaid](#from-mermaid). |

## Ask your agent

```text
Use Plotwork to draw a sankey diagram of where our monthly cloud bill goes: from the bill to each service, then to the team that owns it, and then to the product it pays for, with the amounts in dollars.
```

```text
Use Plotwork to draw a sankey diagram of what happens to 10,000 requests per second: served from the cache or from the origin, rejected, or failed, with failures in red and no amounts on the cards.
```

## Example

**Diagram source**

```diagram
sankey "Request traffic through the platform" unit="req/s" desc="How 12,000 requests per second move from the edge through the API gateway and three services to the data stores, with blocked and failed requests branching off."

node "Edge" icon=lucide:globe
node "API gateway" icon=lucide:server
node "Auth" icon=lucide:key-round
node "Catalog" icon=lucide:book-open
node "Orders" icon=lucide:package
node "Redis cache" icon=simple:redis tone=aqua
node "PostgreSQL" icon=simple:postgresql
node "Search index" icon=lucide:search
node "Blocked" icon=lucide:shield-x tone=red
node "Errors" icon=lucide:circle-alert tone=red

"Edge" -> "API gateway" : 10800
"Edge" -> "Blocked" : 1200

"API gateway" -> "Auth" : 3000
"API gateway" -> "Catalog" : 5200
"API gateway" -> "Orders" : 2200
"API gateway" -> "Errors" : 400

"Auth" -> "Redis cache" : 2600
"Auth" -> "PostgreSQL" : 400
"Catalog" -> "Redis cache" : 3600
"Catalog" -> "PostgreSQL" : 1200
"Catalog" -> "Search index" : 400
"Orders" -> "PostgreSQL" : 1900
"Orders" -> "Errors" : 300
```

*Picture: Request traffic through the platform: 10 nodes and 13 flows in 4 columns, total 12,000 req/s. How 12,000 requests per second move from the edge through the API gateway and three services to the data stores, with blocked and failed requests branching off.*

**Text description**
- Edge to API gateway: 10,800 req/s, 90% of what leaves Edge
- Edge to Blocked: 1,200 req/s, 10% of what leaves Edge
- API gateway to Catalog: 5,200 req/s, 48% of what leaves API gateway
- API gateway to Auth: 3,000 req/s, 28% of what leaves API gateway
- API gateway to Orders: 2,200 req/s, 20% of what leaves API gateway
- API gateway to Errors: 400 req/s, 4% of what leaves API gateway
- Auth to Redis cache: 2,600 req/s, 87% of what leaves Auth
- Auth to PostgreSQL: 400 req/s, 13% of what leaves Auth
- Catalog to Redis cache: 3,600 req/s, 69% of what leaves Catalog
- Catalog to PostgreSQL: 1,200 req/s, 23% of what leaves Catalog
- Catalog to Search index: 400 req/s, 8% of what leaves Catalog
- Orders to PostgreSQL: 1,900 req/s, 86% of what leaves Orders
- Orders to Errors: 300 req/s, 14% of what leaves Orders

The nodes take their tones in turn in the order they first appear, and `tone=` changes one: the cache is aqua, and what goes wrong is red. The ribbon from Edge to Blocked skips two columns, so it has a lane under the gateway and the services and never passes behind a card. Every node here balances, so what goes in comes out and no wedge is drawn. The line under the picture says the unit, the total that enters, and how much there is of it.

## The language

One statement to a line. Text is always in double quotes, options are `key=value`, and `#` starts a comment. Indentation means nothing to the parser.

| Statement | Meaning | Example |
|---|---|---|
| `sankey "Title" unit=<unit> values=<on\|off> sort=<desc\|written> align=<justify\|left> desc="<text>"` | The header. Every option is optional, and so is the title. | `sankey "Traffic" unit="req/s"` |
| `"<From>" -> "<To>" : <amount> tone=<tone>` | One flow. `tone=` colors this ribbon only. | `"Edge" -> "API gateway" : 12000` |
| `node "<Name>" icon=<set:name> tone=<tone>` | The icon and tone of a node, once. | `node "API gateway" icon=lucide:server tone=blue` |
| `desc "<text>"` | The plain-language description, as a statement. The same as `desc=` on the header. | `desc "How requests move."` |

A node's name is its identity, so writing the same name in two flows is one node. Write each pair of nodes once, with the total: a second line for the same pair is refused with `E130`. A `node` line for a name that no flow uses is refused, because a node with no flow has no size.

```plotwork
sankey "Orders" unit="orders"

"Visits" -> "Carts" : 3000
"Visits" -> "Left" : 7000
"Carts" -> "Paid" : 1400 tone=green
"Carts" -> "Abandoned" : 1600 tone=amber
```

### Header options

| Option | Values | Default | Meaning |
|---|---|---|---|
| `unit=` | a short word or symbol, at most 12 characters | none | What the amounts count. `$`, `€`, `£`, and `¥` lead the figure (`$1,240`), `%` follows it (`85%`), and any other unit follows after a space (`120 MB`). A pill shows the unit only when it is a symbol, so a pill stays small. |
| `values=` | `on`, `off` | `on` | `off` hides the amount on each card and each ribbon. The line under the picture still says the total. |
| `sort=` | `desc`, `written` | `desc` | The order of the first column before crossings are removed, and the tie-break after: largest first, or as written. |
| `align=` | `justify`, `left` | `justify` | Where a node with nothing leaving it goes. `justify` puts every one in the last column, so the ends line up. `left` leaves it in the column after its source, which suits a funnel. |
| `desc=` | quoted text | none | The plain-language description. |

### Columns and order

A node sits one column to the right of the furthest node that flows into it. Within a column the order is chosen to cross as few ribbons as possible, and the same source always gives the same picture. A flow that skips a column gets a lane in each column it passes, so it runs between the cards.

With `align=left` a funnel reads from left to right, and each outcome sits beside the stage it leaves:

**Diagram source**

```diagram
sankey "What happens to a request" unit="req/s" values=off align=left desc="Where 10,000 requests per second end up: served from the cache or the origin, rejected, or failed."

node "Requests" icon=lucide:globe
node "Rate limited" tone=amber
node "Unauthorized" tone=amber
node "Server error" icon=lucide:circle-alert tone=red

"Requests" -> "Served" : 9400
"Requests" -> "Rejected" : 600
"Served" -> "From cache" : 6200
"Served" -> "From origin" : 3200
"Rejected" -> "Rate limited" : 350
"Rejected" -> "Unauthorized" : 250
"From origin" -> "200 OK" : 3000
"From origin" -> "Server error" : 200
```

*Picture: What happens to a request: 9 nodes and 8 flows in 4 columns, total 10,000 req/s. Where 10,000 requests per second end up: served from the cache or the origin, rejected, or failed.*

**Text description**
- Requests to Served: 94% of what leaves Requests
- Requests to Rejected: 6% of what leaves Requests
- Served to From cache: 66% of what leaves Served
- Served to From origin: 34% of what leaves Served
- Rejected to Rate limited: 58% of what leaves Rejected
- Rejected to Unauthorized: 42% of what leaves Rejected
- From origin to 200 OK: 94% of what leaves From origin
- From origin to Server error: 6% of what leaves From origin

With `values=off` the picture shows the shapes and the line under it keeps the total. The text description still lists every amount.

### Cost by service, team, and product

When many ribbons cross, the order keeps related nodes together and the gradient shows where each ribbon goes. Amounts of 10,000 and more are drawn compactly on a card (`$42K`), and in full in the text description.

**Diagram source**

```diagram
sankey "Where the cloud bill goes" unit="$" desc="One month of cloud spend, from the bill to the kind of service, the team that owns it, and the product it pays for."

node "Cloud bill" icon=lucide:receipt
node "Compute" icon=lucide:cpu
node "Data" icon=lucide:database
node "Network" icon=lucide:globe

"Cloud bill" -> "Compute" : 42000
"Cloud bill" -> "Data" : 24000
"Cloud bill" -> "Network" : 12000

"Compute" -> "Platform" : 20000
"Compute" -> "Checkout" : 22000
"Data" -> "Platform" : 8000
"Data" -> "Checkout" : 10000
"Data" -> "Search" : 6000
"Network" -> "Platform" : 4000
"Network" -> "Checkout" : 4000
"Network" -> "Search" : 4000

"Platform" -> "Storefront" : 12000
"Platform" -> "Internal tools" : 20000
"Checkout" -> "Storefront" : 30000
"Checkout" -> "Mobile app" : 6000
"Search" -> "Storefront" : 10000
```

*Picture: Where the cloud bill goes: 10 nodes and 16 flows in 4 columns, total $78,000. One month of cloud spend, from the bill to the kind of service, the team that owns it, and the product it pays for.*

**Text description**
- Cloud bill to Compute: $42,000, 54% of what leaves Cloud bill
- Cloud bill to Data: $24,000, 31% of what leaves Cloud bill
- Cloud bill to Network: $12,000, 15% of what leaves Cloud bill
- Compute to Checkout: $22,000, 52% of what leaves Compute
- Compute to Platform: $20,000, 48% of what leaves Compute
- Data to Checkout: $10,000, 42% of what leaves Data
- Data to Platform: $8,000, 33% of what leaves Data
- Data to Search: $6,000, 25% of what leaves Data
- Network to Platform: $4,000, 33% of what leaves Network
- Network to Checkout: $4,000, 33% of what leaves Network
- Network to Search: $4,000, 33% of what leaves Network
- Platform to Internal tools: $20,000, 63% of what leaves Platform
- Platform to Storefront: $12,000, 38% of what leaves Platform
- Checkout to Storefront: $30,000, 83% of what leaves Checkout
- Checkout to Mobile app: $6,000, 17% of what leaves Checkout
- Search to Storefront: $10,000, 100% of what leaves Search

### A pipeline that narrows

A sankey shows where records go as stages drop some of them. Each stage here passes on what it received less what it set aside:

**Diagram source**

```diagram
sankey "Event pipeline" unit="MB/s" align=left desc="Events from three sources through ingest, validation, cleaning, and enrichment. Validation sets aside what is malformed, and the rest reaches the warehouse and the stream."

node "Ingest" icon=lucide:download
node "Validate" icon=lucide:shield-check
node "Enrich" icon=lucide:sparkles
node "Quarantine" icon=lucide:shield-x tone=red

"Mobile apps" -> "Ingest" : 300
"Web" -> "Ingest" : 500
"Partner API" -> "Ingest" : 200
"Ingest" -> "Validate" : 1000
"Validate" -> "Clean" : 940
"Validate" -> "Quarantine" : 60
"Clean" -> "Enrich" : 940
"Enrich" -> "Warehouse" : 700
"Enrich" -> "Stream" : 240
```

*Picture: Event pipeline: 10 nodes and 9 flows in 6 columns, total 1,000 MB/s. Events from three sources through ingest, validation, cleaning, and enrichment. Validation sets aside what is malformed, and the rest reaches the warehouse and the stream.*

**Text description**
- Mobile apps to Ingest: 300 MB/s, 100% of what leaves Mobile apps
- Web to Ingest: 500 MB/s, 100% of what leaves Web
- Partner API to Ingest: 200 MB/s, 100% of what leaves Partner API
- Ingest to Validate: 1,000 MB/s, 100% of what leaves Ingest
- Validate to Clean: 940 MB/s, 94% of what leaves Validate
- Validate to Quarantine: 60 MB/s, 6% of what leaves Validate
- Clean to Enrich: 940 MB/s, 100% of what leaves Clean
- Enrich to Warehouse: 700 MB/s, 74% of what leaves Enrich
- Enrich to Stream: 240 MB/s, 26% of what leaves Enrich

### Flow that does not balance

What goes into a node should come out of it, with the rest drawn as a flow to a node of its own. When it does not, the card gets a dashed wedge as thick as the difference: on its right edge for flow lost, on its left edge for flow gained, with `-60` or `+80` beside it when there is room. Lint warns with `W200`, names the node, and says what to do. A difference of half a percent or less is rounding and draws nothing. A node with flow on one side only, a source or an end, never counts.

```plotwork
sankey "Ingest" unit="MB/s"

"Web" -> "Ingest" : 1000
"Ingest" -> "Validate" : 940
```

Here `Ingest` takes in 1,000 and passes on 940, so it loses 60. Add a flow such as `"Ingest" -> "Duplicates" : 60` to account for it.

### Colour

Each node takes the next of `blue`, `orange`, `aqua`, `violet`, `amber`, `green`, and `neutral` in the order the nodes first appear. Red is left out of the rotation because it is what you choose for a failure, so a node is never red by accident. `tone=` on a node changes it, and `tone=` on a flow colors that ribbon alone. A ribbon takes the tone of the node it leaves and fades to the tone of the node it reaches. Nobody writes a color value: the theme owns every color in light and dark.

### What a card and a ribbon show

A card holds its icon on a chip, its name, and its amount. It is as wide as its widest word and its amount need, and its name wraps to three lines before it is cut with an ellipsis and `W203`. It is as tall as the amount through it, or as its content needs when that is more, and a small card keeps its ribbons in its middle. An amount appears on a ribbon at least 10px thick, where there is a clear place for it, and never over a card or another amount. The text description lists every flow with its amount and its share of what leaves its source.

## Limits and warnings

| Limit | Value | What happens past it |
|---|---|---|
| Nodes | 60 | `E140` before any layout, with the advice to fold the small ones into "Other". |
| Flows | 150 | `E140`. |
| Columns | 12 | `E140`. |
| An amount | above 0 and at most one trillion | `E136`. |
| A unit | 12 characters | `E136`. |
| Columns before the warning | 8 | `W200`. |
| Nodes and flows before the warning | 40 and 100 | `W200`. |

The picture is 880px wide for up to four columns, the width a Markdown column keeps text readable at. More columns need more room, so a picture of eight columns is a little over 1,100px and lint says `W202` when it would shrink text too far. The layout is plain arithmetic, so a sankey of 60 nodes and 150 flows is laid out in a few milliseconds.

Every mistake comes back as a result with a code, a line, a column, and a hint. Warnings never stop the drawing.

| Code | When it fires | Fix |
|---|---|---|
| `E100` | A flow with no amount or no colon, an arrow that is not `->`, a name that is not in quotes, an amount with a comma, a node line for a name no flow uses, a sankey with no flows, or flows that go round in a loop. | Read the hint, which names the line to write. |
| `E101`, `E110`, `E121`, `E122` | An unterminated string, an unknown statement, an unknown tone, or an unknown icon. | Read the hint, which names the nearest choice. |
| `E130` | The same pair of nodes written twice, or a node described twice. | Write it once, with the total. |
| `E135` | An unknown option. | Read the hint. |
| `E136` | An unknown `sort=`, `values=`, or `align=`, a bad unit, or an amount that is not a number or is zero. | Read the hint, which says what to write. |
| `E140` | More than 60 nodes, 150 flows, or 12 columns. | Fold the small ones into one "Other", or split the diagram by stage. |
| `W200` | A node that loses or gains flow, ribbons thinner than 2px, more than 8 columns, or more than 40 nodes or 100 flows. | Account for the difference, fold the small flows into "Other", or split the diagram. |
| `W202` | The picture is wider than a Markdown column and its text would shrink. | Merge stages, or shorten the names. |
| `W203` | A name was cut with an ellipsis. | Shorten the name. |

## From Mermaid

Mermaid `sankey-beta` (and `sankey`, which Mermaid is renaming it to) converts to a sankey. Each line is `Source,Target,Value`, a name with a comma is in double quotes, and a `%%` comment or a blank line is skipped. Ask your agent to convert it, or use the `convert_mermaid` tool. The [migration guide](https://docs.plotwork.dev/migrating-from-mermaid.md) covers the converter in full.

```mermaid-source
---
title: Product budget
config:
  sankey:
    showValues: false
---
sankey-beta

%% source,target,value
Budget,Engineering,600
Budget,Marketing,250
Budget,Support,150
Engineering,Platform,400
Engineering,Application,200
Marketing,Campaigns,200
Marketing,Events,50
Marketing,Events,0
Support,"Help centre, docs",100
Support,Tooling,50
Support,Tooling,0
```

The converter reports the settings it dropped (`M200`) and the two flows of zero it dropped (`M216`):

```plotwork
sankey "Product budget"

"Budget" -> "Engineering" : 600
"Budget" -> "Marketing" : 250
"Budget" -> "Support" : 150
"Engineering" -> "Platform" : 400
"Engineering" -> "Application" : 200
"Marketing" -> "Campaigns" : 200
"Marketing" -> "Events" : 50
"Support" -> "Help centre, docs" : 100
"Support" -> "Tooling" : 50
```

| Mermaid | Plotwork | Report |
|---|---|---|
| `Source,Target,Value` | `"Source" -> "Target" : Value` | none |
| A `source,target,value` line, a `%%` comment, and a blank line | Skipped. | none |
| A name in double quotes, with a comma or a doubled quote | Read as written. | none |
| Front matter `title:` and `accTitle:` | The title. | none |
| `accDescr:` | `desc=` | none |
| Two lines for the same pair of nodes | One flow with the amounts added. | `M209` |
| A flow of zero, and a flow with a node that has no name | Dropped. | `M216` |
| HTML in a name | Removed. | `M205` |
| Front matter `config:` such as `showValues`, `nodeAlignment`, and `linkColor`, and `%%{init}%%` | Dropped. Put the unit on the header with `unit=`, and use `align=left` for left alignment. | `M200` |
| A negative or non-numeric amount, a line that is not a flow, and a flow that closes a loop | Nothing is converted, or the line is skipped when the conversion is lenient. | `M110`, or `M218` |
| More than 60 nodes or 150 flows | Nothing is converted. | `M102` |
| No title | Converted. | `M214` |

## See also

| Doc | Why |
|---|---|
| [Writing diagrams](https://docs.plotwork.dev/writing-diagrams.md) | The flow, sequence and schema languages, and the rules every kind shares |
| [Examples to copy](https://docs.plotwork.dev/examples.md) | Finished diagrams with the prompt that gets an agent to write each one |
| [Migrating from Mermaid](https://docs.plotwork.dev/migrating-from-mermaid.md) | Convert existing Mermaid diagrams, and what is dropped |
| [Treemaps](https://docs.plotwork.dev/treemaps.md) | Where a total goes as nested areas, when the hierarchy matters more than the flow |
| [Pie charts](https://docs.plotwork.dev/pie-charts.md) | The shares of one whole, when there is no flow between stages |
| [Language reference](https://docs.plotwork.dev/reference/language.md) | Every kind, node type, option and diagnostic |
| [Diagram kinds](https://docs.plotwork.dev/reference/language/kinds.md) | The kinds Plotwork draws and the header of each |
