Skein · RyanAI LLC · performance report

Fifteen million rows, and what they cost

The largest migration we have run, measured end to end on one laptop against a live PostgreSQL server. Every figure below came off a real run and can be reproduced from a command in the repository. The failures we found on the way are in here too, because a report with none is not a report.

Source PostgreSQL 16 Hardware one M-series laptop Process single Dated 8 September 2026

The headline

15,699,911
source rows indexed
2.2 min
first import, complete
0.12 s
re-index, nothing changed
278 MB
peak memory

A 2,124 MB database became a 900,000 node graph in 911 MB, holding 800,000 connections. Seventeen source rows became one node, and the rows themselves never moved.

Where the time actually went

Averages hide the only thing worth seeing here, so this is per table.

One import, five tables
TableTreatmentRowsSecondsShare of the time
contactsone node per row400,00084.4
customersone node per row100,00041.1
projectsone node per row200,0008.4
eventssummarised, two hops out10,000,0002
invoicessummarised per customer5,000,000under 1
Total15,700,000136

Read the bottom two rows twice. Fifteen million of the fifteen point seven million rows cost three seconds between them, because they are aggregated inside PostgreSQL and never cross the wire. The entire import is paid for by the 700,000 rows that become nodes, and by nothing else.

Without that, the same database would have produced roughly 15.7 million nodes: the shape of import nobody runs twice, and the reason so many graph projects are abandoned after the first load.

Is it right, though

Speed on wrong numbers is worthless, so the totals were checked against the source rather than trusted.

Fernwood Studio 11359 · nothing open · last activity today
FIGURES ON RECORD   $63,000.00   invoices for Fernwood Studio 11359
RECENT              invoices for Fernwood Studio 11359
                    events for Fernwood Studio 11359

That total is computed over every one of that customer's invoices, and not one of those invoice rows was copied into the graph.

Do not take this on the page. Go and use it.

A performance report is a document full of claims, and this one is no different. The live corpus holds 155,388 records from five separate businesses. Search it, open any of the twenty-five subjects, read the brief and look at the graph. Every timing on that page is measured on your own machine as you use it, not quoted from ours.

What it feels like afterwards

Query performance on the 900,000 node graph
OperationMedian95th percentile
Brief on one subject0.4 ms0.6 ms
Answer a counting question1.6 ms1.6 ms
Search the whole graph63 ms77 ms

A brief costs what the subject's own neighbourhood costs, not what the graph costs. A hundred thousand customers with six connections each is cheap; twenty clients with fifty thousand each is not. The number to watch is the degree of the busiest entity, never the node count.

How far it goes

Summarised tables are nearly free, so the ceiling is set by rows that become nodes. Four sizes, one process, nothing else on the machine.

Entity scaling
Entity rowsNodesRows/secPeak memoryWall clockOn disk
250,000650,0005,618190 MB1.5 min608 MB
1,000,0001,400,0005,144192 MB4.0 min1,320 MB
2,000,0002,400,0004,144194 MB8.8 min2,170 MB
4,000,0004,400,0003,989194 MB17.4 min3,881 MB

Memory is flat. 190 MB at a quarter of a million rows and 194 MB at four million. What the importer holds is bounded by our design rather than by the customer's table.

Throughput falls 8.2% per doubling of the graph, which is database index depth increasing. That is a slope, not a wall: sixteen times the rows costs 1.4 times the seconds per row. From those measured constants, ten million entities is roughly 45 minutes. That last sentence is arithmetic, not a measurement, and is labelled as such wherever we use it.

The figure not to extrapolate is disk. A graph runs about 900 bytes a node, so forty million nodes is 39 GB. Check that against the machine before quoting anybody.

What went wrong, and what it cost

Three defects found by measuring rather than by reasoning. Each one had been shipping, green, and invisible.

The pointer back to the source had no index

Every imported node carries source_ref, the table and key it came from. The importer looks a node up by it once per summary, so a 15.7M row import does 200,000 of them. There was no index, so each was a full scan.

51.7 ms per lookup, 105 minutes of scanning. The index builds in 0.2 seconds and takes the same lookup under a millisecond. It had gone missing because it was created inside the migration that added the column, and once the column joined the schema proper that branch never fired again.

It said it streamed a table. It read the whole thing into memory first

Both database drivers buffer the entire result set the moment the query returns, so reading in batches of 2,000 was slicing a list that was already fully in memory. Measured at 4,000,000 rows, in separate processes: 657 MB against 43 MB, and the query blocked for 1.11 seconds before returning a single row.

A hundred million row table could not have been read at all, on any hardware, which is exactly the size this design exists to serve.

Ten million rows were being dropped in silence

The events table pointed at invoices, invoices were themselves summarised, so there was nothing in the graph to count events against and they were skipped without an error. A summary now follows one more hop, so events are counted per customer. Two hops and no further: past that the join gets expensive and the answer stops meaning anything a person would recognise.

Why these are in a sales document

Every one was found by measuring something we had already claimed. The alternative to publishing them is a report where nothing ever went wrong, which is not a report anybody in this market believes, because they have all been handed one before.

Conditions, so this can be checked

Skein by RyanAI LLC · Missouri · getskein.io. Prepared for evaluation. No customer data appears anywhere in this report: the source is a generated database and the names shown are synthetic.