XY - Possible usage - My read on it.
A charting library that plots ten billion points — how it works, what it costs, and when you shouldn’t use it.
Disclosure: This piece was researched and drafted with Claude, working from the XY source, its design dossier, and its benchmark documentation. Claims were verified locally by creating & running a validation script; the two figures marked [R] are reported by the project's CI and were not independently re-run.
XY is an alpha-stage Python charting library aimed at a specific gap: datasets too large for Plotly or Bokeh but where you still want to pan, zoom, hover and click. The claim that drew me in was that its cost is bounded by screen size rather than data size.
That’s a testable claim, so I tested it. This post walks through what the design does, what the measurements showed, and where I think it does and doesn’t belong.
Who this is written for: you can read Python, you know what an array is, and you’ve made a plot with Matplotlib at some point. Everything else is explained.
Where the numbers come from. Every figure carries its source:
[M] — measured on an Apple Silicon laptop
[C] — derived from constants in
python/xy/config.py[R] — reported by the project’s CI benchmarks in
spec/benchmarks/results.md
The last section covers how to reproduce all of them.
The problem you haven’t run into yet
Most plots are a few hundred points. It feels instant, so you never think about what the library is doing underneath.
Now imagine a file with ten million rows — every flight that took off last year, or every star in a sky survey — and the instruction “plot it.”
The obvious code:
python
import matplotlib.pyplot as plt
plt.scatter(x, y)
plt.savefig("out.png")That took about 860 ms and 580 MB of memory [M]. In Plotly, asking for an interactive chart you can pan and zoom takes 54 seconds and 1.5 GB [R]. In Plotly’s default non-GPU mode, which builds one SVG element per point, it never finishes at all — that path stops being usable somewhere above a million points [R].
Now look at your screen. A typical plot area is around 1200 × 800 pixels: 960,000 pixels total [M]. Ten million things drawn onto fewer than a million dots means 10.4 points share each pixel [M] on average. Nine out of ten are painted over before you ever see them.
The machine did enormous work to produce an image carrying no more information than a much cheaper image would have.
That observation is the foundation of XY’s design.
The core idea
Traditional charting libraries have a cost that scales with how much data you have. Ten times the data, roughly ten times the work.
XY is built so cost scales with how many pixels are on screen.
Your screen doesn’t grow when your dataset does, so past a certain size, plotting more data costs almost nothing extra. The payloads I measured:
Two things there need explaining, and the explanation turned out to be the most interesting part of the exercise.
First, a 10× jump from one million to ten million changes the payload by 0.7% [M]. Second — and this reads like a typo the first time — plotting more data sent less.
Nothing shrank. Those rows are two different formats either side of a switch.
One number, two formats
XY picks a representation based on how many points are visible. The switch is a constant in python/xy/config.py:
python
SCATTER_DENSITY_THRESHOLD = 200_000
DENSITY_GRID = (512, 384)
DENSITY_SAMPLE_TARGET = 8_192Below the threshold: direct mode
Every point ships individually as two 32-bit floats — an x and a y — so 8 bytes per point. 100,000 points is 100,000 × 8 = 800,000 bytes = 781.25 KB [C], which is the 782 KB measured above. Cost grows linearly and without limit; ten million points would be 78 MB.
In exchange, every point is really there. You can hover it, click it, colour it individually.
Above the threshold: density mode
XY stops sending points and sends a picture of where they were:
count grid 512 × 384 cells × 1 byte = 196,608 B = 192 KB
sample overlay 8,192 points × 2 × f32 = 65,536 B = 64 KB
─────────────────
256 KB [C]
+ chart specification metadata ~3 KB
─────────────────
259 KBMeasured: 258–260 KB [M]. The derivation agrees to within 3 KB.
Now notice what’s missing from that calculation: N never appears. The grid is 512 × 384 whether it holds a thousand points or a billion. The sample overlay is 8,192 points regardless. No term in the formula can be influenced by your data size.
That’s the difference between “flat in practice” and “flat by construction.” The payload isn’t flat because someone optimized carefully — it’s flat because the format has no room to be anything else.
The gap that reveals the priorities
Density mode costs 256 KB. Direct mode costs 8 bytes per point. Set them equal:
256 KB ÷ 8 bytes = 32,768 points [C]Above 32,768 points, direct mode is already the more expensive way to send a chart. But XY doesn’t switch there — it switches at 200,000, shipping up to 6.1× more data than it strictly needs to [C] in between.
That gap is deliberate. The LOD specification states the rule directly:
Invariant L2 (exact under budget): if
visible ≤ budget, the user sees real marks with real channels — never a sample, never an aggregate.—
spec/design/lod-architecture.md
Under the budget you get exact hover, exact selection, and a real colour per mark. A density grid provides none of those. Bandwidth is the cheaper thing to spend, so XY spends it.
The payload curve is a step function: linear, then a cliff, then horizontal forever.
What makes it possible
The step function is the visible behaviour. Five design decisions underpin it.
Numbers travel as bytes, not text
When Plotly sends data to the browser, it converts it to JSON:
json
{"x": [1.0, 2.0, 3.0]}A double-precision float is 8 bytes in memory. As JSON text, a realistic coordinate like -73.98452301 runs a dozen characters; with separators the average worked out to 19.9 bytes per number [M] — 5.0× the 4 bytes [M] the same value occupies as a 32-bit float.
Size is only half of it. The browser must then parse every one of those strings back into a number, character by character. For twenty million numbers, that’s hundreds of megabytes of text and a parse that blocks the page.
XY moves data as raw binary float buffers. The design dossier states it as an invariant:
No JSON numbers on the wire; data moves as raw f32 buffers (§29).
The bytes leaving your NumPy array are byte-for-byte the bytes the graphics card reads. The only copies are memcpycopies — blind block moves — never parse-a-number copies.
Transferable idea: text is a fine container for configuration and a poor one for data.
Computation runs where the data already is
Plotly computes in JavaScript, in the browser, on the same thread that handles your clicks. That’s why pages freeze.
XY computes in Rust — a compiled language in C’s performance class — running inside your Python process. The data never leaves the machine to be processed; the browser receives a finished, small result.
(The project’s CI, on slower hardware, records 2 ms at 100,000 points [R].)
Transferable idea: move the computation to the data, not the data to the computation. It’s why databases push filters down to storage.
The graphics card draws the marks
Plotly’s default scatter creates one SVG element per point — a real object in the page’s document tree, like a <div>, carrying hundreds of bytes of structure and style. Browsers degrade badly past ten to a hundred thousand of them, which is why that path takes 44 seconds at one million points and 113 seconds at three million [R].
XY draws through WebGL2, the browser’s interface to your GPU (js/src/50_chartview.ts). GPUs exist to run the same small operation on millions of items at once. XY uses instanced rendering: upload one marker shape, then issue a single command meaning “draw this 500,000 times, at these positions.”
The HTML document is still used — for axis labels, legend and tooltips. Small things, few of them.
Worth knowing: the GPU holds only a cache. Canonical data stays CPU-side in 64-bit precision, and every GPU buffer is droppable and rebuildable. Hover, selection, export and zoom all need the real values, and data that exists only in video memory can’t serve them.
Level of detail
The same idea games use: a distant mountain isn’t rendered rock by rock.
XY defines four tiers and picks one per chart, re-picking as you zoom (spec/design/lod-architecture.md):
Tier 0 — Direct. Draw every point, exactly. No approximation.
Tier 1 — Decimation, for lines. A stock price with 100 million samples on a 1,200-pixel-wide chart gives each pixel column ~83,000 samples. But a pixel column can only show a vertical segment, so what matters is the first, last, minimum and maximum value in that column. Keep four, discard the rest.
This is the M4 algorithm (Jugel et al., VLDB 2014), and its property is unusual: the reduced line rasterizes to an image identical to the full-data line. Not similar — identical.
That’s testable, so the harness tests it. Rasterizing a 2,000,000-point signal with sharp spikes at 1200px wide, both fully and M4-reduced, then comparing every pixel column:
points in: 2,000,000
points kept: 4,777 (419× reduction) [M]
M4 max deviation: 0.0 [M]
naive subsampling,
same budget: 302.16 [M]Zero. Uniform subsampling to the identical budget misses spikes by 302 units. Same number of points kept; one method is exact and the other isn’t.
Tier 2 — Density surfaces, for scatter. Scatter points have no order, so dropping some would genuinely distort the picture. Instead XY bins them into the fixed grid described above.
Tier 3 — Out-of-core tiles. Beyond RAM, canonical data stays on disk and only the tiles the current view needs are read — the way map applications load tiles as you pan.
A floating-point trap worth remembering
This one will show up in your own code someday.
GPUs prefer 32-bit floats over 64-bit — half the memory, faster to process. So the obvious optimization is storing coordinates as float32.
A float32 has a 24-bit mantissa: about 7 significant decimal digits total (not 7 after the decimal point).
Now consider time. Computers commonly store timestamps as milliseconds since 1 January 1970 — currently around 1,700,000,000,000, thirteen digits. Seven digits of precision for a thirteen-digit number leaves the last six as noise.
Measured precisely:
gap between neighbouring float32 values near 1.7e12:
131,072 ms = 2 minutes 11 seconds [M]
a day of per-second readings (86,400 samples):
660 distinct x-positions survive [M]A day of data becomes 660 columns. No exception, no warning — just a chart that’s quietly wrong.
The fix is a change of coordinates, not more bits. Keep full precision in Python; compute a per-column offset; subtract it before the cast:
raw: 1_700_000_000_000, 1_700_000_001_000, 1_700_000_002_000
offset: 1_700_000_001_000
relative: -1_000, 0, 1_000Those small numbers fit a float32 with room to spare. The offset stays in Python as a 64-bit value and is added back during rendering. Round-tripping the same timestamps this way gave a maximum error of 0 ms at 4 bytes per value [M] — same storage, exact results. When you zoom far enough that precision tightens again, XY re-centres the offset.
The technique isn’t XY’s invention. deck.gl adopted it and then removed their far more complex emulated-64-bit code, reporting that the offset approach “rivals 64-bit precision at 32-bit speeds.” Cesium uses the same idea under the name RTC.
Transferable idea: when an optimization looks free, find out what it costs. And when precision fails, ask whether you can move the origin before asking for more bits.
Summarising without distorting
Here’s a question that sounds simple and isn’t.
You’re plotting a million flights, coloured by airline — red for one, blue for another. Zoomed out, XY is in density mode, and one cell now covers 500 flights of mixed colour.
What colour is that cell?
Each obvious answer distorts something:
Colour by count alone — the airline information is gone entirely.
Majority wins — a cell holding 251 red and 249 blue renders as pure red. Minorities vanish.
Brightest or maximum — one outlier speaks for 500 points.
XY’s rule, from spec/design/lod-architecture.md:
the density surface wears the data’s own colors, and it composites like the data’s own points
Each cell shows the alpha-weighted mean of its points’ actual colours, averaged in linear light, and its transparency is set to what that many overlapping semi-transparent marks would physically produce — 1 − (1 − a)ᵏ for k points.
The consequence: the zoomed-out surface and the zoomed-in points are the same image at two magnifications. Zoom in and the surface dissolves into individual marks with the same hue and brightness. Nothing jumps.
The “linear light” detail is doing real work. Screen colours are stored in sRGB, a non-linear encoding matched to human perception. Averaging red and blue as raw bytes gives a dark purple of 128; converting to linear light, averaging, then converting back gives 188 [M] — which is what overlapping red and blue light actually produces. Both values verified exactly.
Where a channel has no honest aggregate — marker size has no meaningful average — XY drops it and renders a visible “aggregated” badge, so a summary can’t be mistaken for raw data.
The project’s own competitive research is candid that most of XY’s parts exist elsewhere: GPU rendering, binary transport, density aggregation and tile pyramids are all prior art. What it identifies as new is the combination together with these truthfulness rules.
Points that remember where they came from
If XY sends a grid instead of points, how do you click a flight and see its callsign?
The design is indices, not payloads. Hover and selection return row positions into your original data:
python
import xy, pandas as pd
flights = pd.read_parquet("flights.parquet") # 10M rows, 40 columns
def on_select(sel):
picked = flights.iloc[sel.index] # full records, all 40 columns
print(picked.groupby("airline").size())
xy.scatter_chart(
xy.scatter(x="lon", y="lat", color="airline", size="altitude", data=flights),
on_select=on_select,
)Verified: box-selecting a region returned 3,163 indices where a NumPy mask predicted 3,163 — exact match [M].
The reason for the design is arithmetic. Attaching a 40-byte callsign to each of ten million points would add 400 MB to the wire and dismantle everything above. Row position costs nothing — it already exists implicitly in array order.
The GPU knows geometry. Python knows everything. Array position links them.
One consequence to plan around: in density mode there are no individual points to hover, so you get a cell summary plus a drill to the top rows in that cell. Zoom past the budget and exact per-point hover returns.
Where XY fits
Large scientific and engineering data. Sensor logs, particle physics, sky surveys, genomics — millions of rows you need to look at, not just compute on.
Time series and monitoring. Offset encoding keeps timestamps exact, M4 keeps long histories cheap, and appends cost only the size of the append rather than a full re-send, so live dashboards stay responsive.
Work that needs points back, not just pixels. Select a region, get the records. The main alternative for very large scatter plots, datashader, returns a finished image — there’s nothing to click.
Ordinary small charts. XY isn’t only a big-data tool: 0.4 ms to build a 100,000-point chart [M] against 159 ms for Plotly to serialize the same chart to HTML [M]. Small-data startup is a tracked benchmark category with an explicit goal of beating Plotly, Bokeh and Altair on first paint [R].
Where XY is the wrong tool
Games and general graphics. XY uses the GPU, but nothing about it generalises. It’s event-driven: it draws when something changes, then goes idle. Games do the opposite — simulate and draw every frame, continuously. XY’s central optimization is that panning updates a few GPU variables and never touches data buffers, which is precisely wrong for a world that changes each tick. The mark types are also a fixed set of about twenty; the plugin documentation is explicit that a plugin returns “ordinary XY marks — not shaders, not draw calls.” That restriction is what lets plugins inherit hover, selection and export for free, but custom geometry needs a different tool. Use PixiJS or Three.js on the web, Godot or Unity natively.
Native mobile apps. The Rust engine runs inside a Python process. Phones don’t have one. A browser-embeddable bundle exists and would work in a WebView, and a browser-free Rust rasterizer can produce PNGs server-side, but iOS and Android aren’t supported targets.
Charts XY doesn’t have yet. Plotly ships around forty trace types; XY has scatter, line, area, bar, histogram, box, violin, hexbin, heatmap, contour, error bars and a handful more. Absent: treemap, sunburst, Sankey, radar, gauge, waterfall, funnel — and candlestick/OHLC, which was prototyped on a branch whose pull request was closed unmerged.
For candlesticks today, TradingView’s Lightweight Charts is the strongest free option — candlesticks, time axis, crosshairs, price scales and volume panes in about 35 KB, purpose-built for finance rather than bolted on. It’s JavaScript;lightweight-charts-python wraps it. For a static image in a report, mplfinance is simplest.
There’s an elegant footnote there. Candlestick reduction is first/max/min/last per pixel column — exactly the M4 algorithm XY already ships for lines. And for candles that reduction isn’t even an approximation: merging sixty one-minute candles into one hourly candle is just a coarser candle, lossless in the chart’s own language. The architecture suits OHLC unusually well; the mark simply hasn’t been built.
The ordering looks deliberate rather than accidental. The shared primitives — rectangles, filled polygons, grid textures — came first, because once those exist each new chart type is largely wiring. Breadth is cheap after depth.
Anything small and routine. With 5,000 points, XY offers nothing Matplotlib doesn’t, and Matplotlib is twenty years mature, installed everywhere, and thoroughly documented by strangers who hit your problem first.
XY is in alpha. Its design dossier maintains a public list of unresolved issues: the tier decision doesn’t yet account for GPU fill rate, logarithmic axes skip the tile pyramid and take a full rescan, brightness can shift when zooming certain coloured surfaces. Documented openly — but this is a project under active construction, not a settled dependency.
Reproducing these numbers
Everything marked [M] comes from scripts/validate_article_claims.py in the repository. It checks each claim against the value asserted here and prints PASS or FAIL, exiting non-zero on failure so it can run in CI:
bash
python3 scripts/validate_article_claims.py --sizes 1e5,1e6,1e7The checks come in three tiers. Tier A is arithmetic and needs only NumPy — the float32 timestamp collapse, the offset-encoding round trip, the linear-light colour figures, the M4 pixel comparison, and the payload derivation read fromconfig.py. Tier B exercises the engine and needs the native Rust core (cargo build --release). Tier C runs Matplotlib and Plotly for comparison and skips gracefully if they aren’t installed.
Two habits came out of building it that I’d carry to any measurement work.
Pin the value, not just the trend. An early version of the payload check verified only that the payload didn’t grow with N. It passed — while the figure it was being compared against was three times too large, because that figure came from a benchmark table predating a compression change. A test that checks a trend without pinning the value will certify a wrong number without complaint. The check now asserts flatness and an absolute band, independently.
Benchmark numbers carry a date. spec/benchmarks/results.md labels its retained tables with the run that produced them and marks superseded figures explicitly. Reading that metadata is part of reading the number.
Two figures here are marked [R] and weren’t re-measured locally: Plotly’s 54 seconds and 1.5 GB at ten million points, and the SVG-path collapse timings. They’re consistent with the smaller measurements I did take, but they’re labelled as reported rather than measured because that’s what they are.
That labelling habit is one the project itself follows — its design dossier flags its own “Plotly uses 40–100 bytes per point” figure as an estimate pending measurement rather than publishing it as fact.
What I’d carry away
Four ideas here outlive this particular library.
Bound the work by the output, not the input. The screen holds a million pixels. That’s the real budget. Any system with a fixed-size output has a similar bound available, and most don’t take it.
Text is a poor container for numbers. 19.9 bytes versus 4, plus a parse on the receiving end. Binary formats aren’t premature optimization at scale; they’re the difference between working and not.
Move computation to the data. Rust inside the Python process rather than JavaScript in the browser. The fastest network request is the one never made.
The naive optimization is often silently wrong. “Use float32, it’s smaller” turns a day of per-second data into 660 columns with no error raised. The fix wasn’t more precision — it was moving the origin so the numbers got small.
And one worth stealing from how the project documents itself: XY publishes a list of its own unsolved problems, marks unverified claims as estimates, and credits the prior work each idea came from. That practice is rarer, and more useful, than any technique above.
Sources
spec/design-dossier.md— architecture, invariants, competitive researchspec/design/lod-architecture.md— tier ladder, aggregation truthfulness rulesspec/benchmarks/results.md— CI benchmark methodology and resultspython/xy/config.py— tuning constants used in the payload derivationsscripts/validate_article_claims.py— the verification harnessJugel et al., M4: A Visualization-Oriented Time Series Data Aggregation, VLDB 2014

