+1 (415) 649-9454

Importing Relational Data Without the (Dead) ETL Tool

For years the stock answer to "how do I get my Postgres/Oracle/SQL Server data into Neo4j?" was the Neo4j ETL Tool. That answer is dead. The ETL Tool was a Neo4j Labs project — unsupported by definition — and its last meaningful releases were years ago; the old neo4j.com/developer/neo4j-etl/ page now just redirects to the generic relational-import docs. Neo4j's own 2025 messaging around Aura Graph Analytics was literally "No More ETL".

This tutorial is the replacement we hand to clients. It covers the four tools that actually carry relational data into Neo4j in 2026, when to use each, and the idempotent loading pattern that keeps re-runs safe.

Data sizeInstance stateUse
Up to low millions of rowsRunningNeo4j Data Importer (UI) or LOAD CSV
Tens of millions to billionsEmpty databaseneo4j-admin database import full
Large tables already in a lakehouseRunningNeo4j Connector for Apache Spark
Continuous change feedRunningNeo4j Connector for Kafka

We will use a small order-management schema as the running example: customers(id, name, email), products(sku, name, price), orders(id, customer_id, placed_at), order_lines(order_id, sku, qty).

Step 0 — decide the graph model first

Relational-to-graph is a modelling exercise, not a copy. The rule of thumb: entity tables become node labels, foreign keys become relationships, and join tables become relationships with properties.

(:Customer {id, name, email})
(:Order {id, placedAt})
(:Product {sku, name, price})
(:Customer)-[:PLACED]->(:Order)
(:Order)-[:CONTAINS {qty}]->(:Product)

Create the constraints before loading anything. They give you uniqueness guarantees and the index that makes MERGE fast:

CREATE CONSTRAINT customer_id IF NOT EXISTS FOR (c:Customer) REQUIRE c.id IS UNIQUE;
CREATE CONSTRAINT order_id    IF NOT EXISTS FOR (o:Order)    REQUIRE o.id IS UNIQUE;
CREATE CONSTRAINT product_sku IF NOT EXISTS FOR (p:Product)  REQUIRE p.sku IS UNIQUE;

Option 1 — Neo4j Data Importer (small to medium, no code)

Data Importer is built into Aura and available for self-managed instances through the Neo4j Workspace. You drag in CSV files exported from your RDBMS, draw the node labels and relationships on a canvas, map columns to properties, and it generates and runs the MERGE statements for you. It also lets you save the mapping model as JSON so the load is repeatable.

Export the tables first:

psql "$DATABASE_URL" -c "\copy customers   to 'customers.csv'   csv header"
psql "$DATABASE_URL" -c "\copy orders      to 'orders.csv'      csv header"
psql "$DATABASE_URL" -c "\copy products    to 'products.csv'    csv header"
psql "$DATABASE_URL" -c "\copy order_lines to 'order_lines.csv' csv header"

Data Importer is the right choice for a proof of concept or a one-off migration of a few million rows. Its limits are file size (browser upload) and the fact that it is interactive; for anything you need to run on a schedule, move on to the options below.

Option 2 — LOAD CSV with idempotent MERGE

LOAD CSV is still the workhorse for medium loads into a running database and it is scriptable. The pattern that matters is idempotence: a load you can run twice without duplicating anything. That means MERGE on the key only, and SET everything else.

LOAD CSV WITH HEADERS FROM 'file:///customers.csv' AS row
CALL (row) {
  MERGE (c:Customer {id: toInteger(row.id)})
  SET c.name = row.name,
      c.email = row.email
} IN TRANSACTIONS OF 10000 ROWS;
LOAD CSV WITH HEADERS FROM 'file:///orders.csv' AS row
CALL (row) {
  MATCH (c:Customer {id: toInteger(row.customer_id)})
  MERGE (o:Order {id: toInteger(row.id)})
  SET o.placedAt = datetime(row.placed_at)
  MERGE (c)-[:PLACED]->(o)
} IN TRANSACTIONS OF 10000 ROWS;
LOAD CSV WITH HEADERS FROM 'file:///order_lines.csv' AS row
CALL (row) {
  MATCH (o:Order {id: toInteger(row.order_id)})
  MATCH (p:Product {sku: row.sku})
  MERGE (o)-[l:CONTAINS]->(p)
  SET l.qty = toInteger(row.qty)
} IN TRANSACTIONS OF 10000 ROWS;

Notes from production:

  • CALL (row) { ... } IN TRANSACTIONS replaced the old USING PERIODIC COMMIT, which was removed in Neo4j 5. If you see PERIODIC COMMIT in a script, it is a 4.x script.
  • Always cast. CSV gives you strings; toInteger, toFloat, and datetime are what make your properties queryable.
  • Load nodes before relationships, and MATCH (not MERGE) the endpoints when creating relationships so a typo in a foreign key fails loudly instead of creating an orphan node.

Option 3 — neo4j-admin database import (bulk, empty database)

For an initial load of hundreds of millions of rows, nothing beats the offline importer. It writes store files directly, skips the transaction log, and routinely loads tens of thousands of rows per second per core. The trade-off: the target database must not exist yet (or must be empty and stopped), so this is for initial migrations, not incremental updates.

It wants CSV headers in a specific form — :ID, :LABEL, :START_ID, :END_ID, :TYPE, and typed property columns. Export accordingly:

-- customers_nodes.csv
copy (select id as "id:ID(Customer)", name, email from customers) to '/tmp/customers_nodes.csv' csv header;
-- placed_rels.csv
copy (select customer_id as ":START_ID(Customer)", id as ":END_ID(Order)" from orders) to '/tmp/placed_rels.csv' csv header;

Then, with the container stopped or on the server host:

neo4j-admin database import full \
  --nodes=Customer=import/customers_nodes.csv \
  --nodes=Order=import/orders_nodes.csv \
  --nodes=Product=import/products_nodes.csv \
  --relationships=PLACED=import/placed_rels.csv \
  --relationships=CONTAINS=import/contains_rels.csv \
  --overwrite-destination \
  neo4j

Start the database, then create the constraints from Step 0 — the importer does not create them for you, and MERGE-based incremental loads afterwards will need them.

Option 4 — the Spark connector (tables that live in a lakehouse)

If your source tables already sit in Databricks, EMR, or any Spark runtime, the Neo4j Connector for Apache Spark lets you write DataFrames straight into the graph with the same MERGE-on-key semantics, parallelised across executors.

(orders_df
  .write.format("org.neo4j.spark.DataSource")
  .mode("Overwrite")                       # Overwrite => MERGE on node.keys
  .option("url", "neo4j://graph.internal:7687")
  .option("authentication.basic.username", "neo4j")
  .option("authentication.basic.password", dbutils.secrets.get("neo4j", "pw"))
  .option("labels", ":Order")
  .option("node.keys", "id")
  .save())

Relationships use relationship, relationship.source.labels, relationship.target.labels, and the matching .node.keys options. Batch size and partition count are the knobs that matter; we typically start at 5,000 rows per batch and one partition per executor core, then tune against the database's transaction-log throughput.

Option 5 — the Kafka connector (continuous sync)

When the relational system stays the system of record and the graph must track it, put Debezium (or your database's native CDC) on the source and the Neo4j Connector for Kafka on the sink side. The sink takes each message and applies a Cypher template, which is the same idempotent MERGE you wrote above:

{
  "neo4j.cypher.topic.orders": "MERGE (o:Order {id: event.id}) SET o.placedAt = datetime(event.placed_at) WITH o MATCH (c:Customer {id: event.customer_id}) MERGE (c)-[:PLACED]->(o)"
}

Because every statement is a MERGE, replaying a topic from the beginning is safe — a property you will be grateful for during your first consumer outage.

Validating the load

Before calling any migration done, count in both systems:

MATCH (c:Customer) RETURN count(c) AS customers;
MATCH (:Customer)-[r:PLACED]->(:Order) RETURN count(r) AS placedOrders;
MATCH (o:Order) WHERE NOT (:Customer)-[:PLACED]->(o) RETURN count(o) AS orphanOrders;

orphanOrders should be zero. If it is not, your foreign keys are dirtier than the RDBMS let on — a finding clients are rarely happy about but always need.

Which one should you pick?

Initial migration of a big database: neo4j-admin database import, then constraints, then LOAD CSV or Kafka for deltas. Already on Spark: the Spark connector for both. Proof of concept: Data Importer, and keep the mapping JSON. Live sync: Kafka.

What you should not do in 2026 is reach for the Neo4j ETL Tool, or any unsupported Labs project, on a system you will have to maintain. If you inherited a pipeline built on it, we can help you replace it — get in touch and we will scope the swap.