Skip to content
Back to Insights

Optimising Neo4J Bulk Import

Lessons from loading billion-node graphs – trading off speed, cost, and data quality.

Saurabh Mehrotra
Saurabh Mehrotra
Director at Xpergia

If you’ve worked with Neo4J’s bulk import tool on anything beyond a toy dataset, you’ll know that the defaults don’t cut it. I’ve spent a fair amount of time tuning imports for large graphs – billions of nodes and relationships – and along the way I’ve picked up a few lessons about the trade-offs that I wish someone had written down for me earlier.

This post shares my perspective on getting the most out of the bulk import tool. For the official reference on available options and syntax, I’d recommend starting with the Neo4j bulk import documentation.

A note on versions: If you’re on Neo4j 5.x, the CLI syntax has changed. The import tool now lives under neo4j-admin database import full and neo4j-admin database import incremental. The options I discuss below apply to this newer structure. Always check the official docs for the exact syntax for your version.

Why Full Loads Are Still the Norm

The incremental import option introduced in Neo4j 5 is a welcome addition, but in my experience it hasn’t replaced full loads for most use cases. The reason is simple: incremental mode only lets you append new nodes and relationships. You can’t update existing properties or delete anything.

Choosing Your Import Strategy Need to update or delete data? YES FULL LOAD Rebuilds entire database NO INCREMENTAL Appends only – no updates DB must be stopped Use --overwrite-destination Updates & deletes Full restructuring Expensive at scale DB can stay running Fast for additions Minimal downtime No updates/deletes No restructuring
Decision flow: full load covers all cases but is expensive; incremental is fast but append-only.

In the real world, graph data evolves – nodes change, relationships are restructured, properties get corrected. That almost always means a full load.

Two things caught me off guard early on. First, if you’re running iterative full loads (which you almost certainly will during development), make sure you use the --overwrite-destination flag – without it, the import refuses to write to an existing database. Second, the target database needs to be stopped during import – easy to overlook when planning maintenance windows.

neo4j-admin database import full \
  --overwrite-destination \
  --nodes=nodes_header.csv,nodes_part_*.csv \
  --relationships=rels_header.csv,rels_part_*.csv \
  neo4j

Speed vs Cost: Start with the Free Levers

My instinct when imports were running slow was to throw better hardware at the problem – faster SSDs, locally-attached NVMe. And to be fair, storage does matter. I’ve seen significant speed improvements moving from network-attached storage to high-performance block storage.

What I wish I’d done first, though, is tune the options that cost nothing.

Optimisation Priority – Free First, Then Paid 1 --threads Match to CPU cores Reduce contention FREE 2 --max-off-heap-memory Fewer disk merge passes Biggest single win FREE 3 --high-parallel-io For fast storage only More concurrent ops FREE ▼ Only if the above isn't enough ▼ 4 Upgrade Storage Hardware NVMe / locally-attached SSD / high-perf block storage $$$
Tune the free levers first – storage upgrades should be your last resort, not your first.

The --threads option was the biggest surprise for me. On a machine with plenty of cores, increasing the thread count made a noticeable difference. Conversely, on a smaller instance where I was competing for resources, dialling threads down actually helped by reducing contention.

The --max-off-heap-memory lever was the single most effective change I made. The importer uses off-heap memory for sorting, and if you’re not allocating enough, it falls back to disk-based merge passes – which is slow. On machines with available RAM, bumping this up is a no-brainer.

neo4j-admin database import full \
  --overwrite-destination \
  --threads=16 \
  --max-off-heap-memory=16G \
  --high-parallel-io=true \
  --nodes=nodes_header.csv,nodes_part_*.csv \
  --relationships=rels_header.csv,rels_part_*.csv \
  neo4j

Quality vs Speed: The —bad-tolerance Balancing Act

Getting --bad-tolerance right took me a few painful iterations. Set it too low and every import run dies after a handful of errors – fix, re-run, repeat. Set it too high and you’re drowning in a log file full of noise where it’s hard to find the critical issues.

The --bad-tolerance Iteration Workflow Start with moderate tolerance --bad-tolerance=1000 Run import → review errors Categorise by root cause Critical errors remaining? YES Fix CSV pipeline → re-run NO Only acceptable errors remain Final import config: --bad-tolerance=999999 --skip-bad-entries-logging=true Suppress known errors → fast import
Iterate: start moderate, fix root causes, then open up tolerance for the final clean run.

What worked for me was starting with a moderate tolerance, reviewing the error report to understand the categories of issues, fixing root causes in my CSV pipeline, and then gradually tightening tolerance. The optimal number depends entirely on your data.

Eventually you reach a point where the remaining errors are acceptable – for example, relationships pointing to nodes that belong to a future import iteration. At this point, crank tolerance up and suppress logging:

neo4j-admin database import full \
  --overwrite-destination \
  --threads=16 \
  --max-off-heap-memory=16G \
  --bad-tolerance=999999 \
  --skip-bad-entries-logging=true \
  --auto-skip-subsequent-headers=true \
  --nodes=nodes_header.csv,nodes_part_*.csv \
  --relationships=rels_header.csv,rels_part_*.csv \
  neo4j

Tip: If you’re loading from multiple CSV files that share a schema, enable --auto-skip-subsequent-headers. Without it, redundant header rows in subsequent files get treated as bad data, inflating your error count and sending you on a wild goose chase.

Keep Data Cleansing Out of the Import

This is an opinion I feel strongly about. The import tool offers options like --ignore-empty-strings, --trim-strings, and --normalize-types that handle basic data cleansing during load. They’re tempting – but in my experience, not worth it.

Separation of Concerns: Cleansing vs Import ✗ Avoid: Mixed Responsibilities Raw Data → ETL Pipeline neo4j-admin import --trim-strings=true --ignore-empty-strings=true --normalize-types=true Slower • Harder to debug ✓ Better: Clean Separation Raw Data → ETL Pipeline Trim, validate, normalise here Output: clean CSVs neo4j-admin import Just loads – fast & predictable
Cleanse upstream in your ETL. Let the importer focus on one job: loading data fast.

I once had --normalize-types silently coerce values in ways I didn’t expect, and debugging that was painful because I wasn’t looking at the import step as the source of the problem. When cleansing and import are mixed, you end up unsure whether a data issue came from your transformation code or from an import option you forgot you’d enabled.

Clean your data upstream. Let the importer just import.

Watching the Clock on Large Imports

When you’re importing billions of records, the process can run for hours. I’ve found it helpful to redirect console output to a log file so I can review progress stages and throughput rates after the fact.

# Redirect output while still watching in real-time
neo4j-admin database import full \
  --overwrite-destination \
  --threads=16 \
  --max-off-heap-memory=16G \
  --nodes=nodes_header.csv,nodes_part_*.csv \
  --relationships=rels_header.csv,rels_part_*.csv \
  neo4j 2>&1 | tee import_$(date +%Y%m%d_%H%M).log

It also helps to partition input files into manageable chunks – not just for performance, but for debuggability. When something goes wrong, it’s much easier to trace the issue to a specific file rather than hunting through a monolithic CSV.

Wrapping Up

The bulk import tool is powerful, but the defaults are tuned for convenience, not for large-scale performance. The biggest wins I’ve found come from tuning threads and memory (free), being strategic about --bad-tolerance (saves time), and keeping data cleansing separate from import (saves sanity). Faster storage helps too, but it should be your last resort, not your first.

For the full list of available options and their current defaults, refer to the official Neo4j documentation. Option names and behaviours can change between versions, so always cross-reference with the docs for your specific Neo4j release.

Saurabh Mehrotra

Director at Xpergia

Part of the Xpergia team helping enterprises transform through practical AI implementation.