Why the Graph Database's Own Query Language Had to Be Skipped for the Bulk Load

Loading data into a graph database usually means writing it in the graph database's own query language. For one real workload — 388 million edges — that turned out to be the one approach guaranteed not to finish. The fix wasn't a tuning flag or a bigger machine. It was skipping the query language entirely.

What the corpus actually looked like

The dataset being loaded was a real production link graph: 75.6 million URL nodes, 388.3 million links between them, measured directly from Postgres's own row estimates. Before writing a single line of loader code, the plan was to check whether anyone else had already tried loading a graph anywhere near this size through Cypher, the graph-query language the target extension speaks.

What other people's attempts had already shown

They had, and the results were public. One reported issue documented an import of 674,103 edges using Cypher's UNWIND combined with CREATE — ordinary, textbook bulk-insert syntax — stalling at 2,600 edges after two hours of runtime. A separate, independent report showed the same collapse at a smaller scale: roughly 83,000 edges taking about an hour, against 14 minutes for the same data loaded into a different graph database entirely. Neither report was a tuning complaint about a slow but eventually-finishing job. Both described a wall.

Why the wall exists at all

The root cause named in both reports is structural, not a matter of missing an index or a misconfigured setting: an edge's endpoint identifiers live inside a packed properties structure, and there's no usable index into that structure for the specific lookup the CREATE clause needs to perform per edge. Every additional edge Cypher inserts makes every subsequent edge's endpoint lookup more expensive, so the cost of loading an edge grows with however many edges already exist — for genuinely small graphs, painfully slow but survivable; extrapolated out to a few hundred million edges, the load time doesn't shift from "slow" to "faster with patience." It moves into "on the order of a century."

What "supported" looked like instead

Both reports pointed at the same real workaround, not a hack around the extension's design: a Cypher graph's actual data lives in plain Postgres tables underneath, one table per label, and nothing prevents inserting directly into those tables with an ordinary SQL INSERT — bypassing the query language's own CREATE statement entirely, while still producing a graph the query language can read and traverse afterward. The reported speedup from doing this instead of the naive approach: up to roughly a thousand times faster for the load step specifically.

The script that had already tried the slow way

An earlier, unfinished loader script already existed in the repository, untracked and apparently abandoned mid-attempt. It used exactly the shape the public reports warn against — UNWIND plus CREATE for vertices, and for edges, a MATCH-based property lookup layered on top of the same CREATE pattern, which is strictly more expensive per edge than the already-nonviable baseline. It was left in place rather than deleted outright — evidence of a real earlier attempt worth keeping visible, not an active problem — but nothing about it was extended or reused. The actual loader was written from scratch around the direct-table-insert approach, taking care that every generated identifier still came from the graph engine's own sequence rather than being assigned by hand, so a future write through the query language proper couldn't collide with anything the bulk load had already created.

The value of checking before building

None of this required experimentation against the real 388-million-edge corpus to discover. It required reading two publicly filed issues against the exact library in question before writing a loader at all, taking their numbers at face value, and doing the one multiplication — a two-hour stall at a few thousand edges, extrapolated to hundreds of millions — that turns "this seems slow" into "this specific approach cannot ever finish." The fix that actually shipped worked because the failure mode it was avoiding was never encountered directly. It was read about, believed, and designed around before the first real load attempt ever ran.

Add new comment

Restricted HTML

  • Allowed HTML tags: <a href hreflang> <em> <strong> <cite> <blockquote cite> <code> <ul type> <ol start type> <li> <dl> <dt> <dd> <h2 id> <h3 id> <h4 id> <h5 id> <h6 id>
  • Lines and paragraphs break automatically.
  • Web page addresses and email addresses turn into links automatically.
Please share this article on your favorite website or platform.