Sankey diagrams
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. |
Ask your agent
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.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
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:globenode "API gateway" icon=lucide:servernode "Auth" icon=lucide:key-roundnode "Catalog" icon=lucide:book-opennode "Orders" icon=lucide:packagenode "Redis cache" icon=simple:redis tone=aquanode "PostgreSQL" icon=simple:postgresqlnode "Search index" icon=lucide:searchnode "Blocked" icon=lucide:shield-x tone=rednode "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" : 300The 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.
sankey "Orders" unit="orders"
"Visits" -> "Carts" : 3000"Visits" -> "Left" : 7000"Carts" -> "Paid" : 1400 tone=green"Carts" -> "Abandoned" : 1600 tone=amberHeader 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:
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:globenode "Rate limited" tone=ambernode "Unauthorized" tone=ambernode "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" : 200With 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.
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:receiptnode "Compute" icon=lucide:cpunode "Data" icon=lucide:databasenode "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" : 10000A 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:
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:downloadnode "Validate" icon=lucide:shield-checknode "Enrich" icon=lucide:sparklesnode "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" : 240Flow 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.
sankey "Ingest" unit="MB/s"
"Web" -> "Ingest" : 1000"Ingest" -> "Validate" : 940Here 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 covers the converter in full.
---title: Product budgetconfig: sankey: showValues: false---sankey-beta
%% source,target,valueBudget,Engineering,600Budget,Marketing,250Budget,Support,150Engineering,Platform,400Engineering,Application,200Marketing,Campaigns,200Marketing,Events,50Marketing,Events,0Support,"Help centre, docs",100Support,Tooling,50Support,Tooling,0The converter reports the settings it dropped (M200) and the two flows of zero it dropped (M216):
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 | The flow, sequence and schema languages, and the rules every kind shares |
| Examples to copy | Finished diagrams with the prompt that gets an agent to write each one |
| Migrating from Mermaid | Convert existing Mermaid diagrams, and what is dropped |
| Treemaps | Where a total goes as nested areas, when the hierarchy matters more than the flow |
| Pie charts | The shares of one whole, when there is no flow between stages |
| Language reference | Every kind, node type, option and diagnostic |
| Diagram kinds | The kinds Plotwork draws and the header of each |