Neptune, JanusGraph, and TigerGraph all store graphs. None of them store your graph the way Neo4j will, and the gap is rarely the data — it is the query language, the ID strategy, and the cutover. We run several of these migrations a year, and the projects that go badly are the ones that treat it as an export/import task. This tutorial walks the whole path: assessing the source graph, translating Gremlin to Cypher, moving the data in bulk, validating that nothing was lost, and cutting over without a long freeze.
The examples use Amazon Neptune as the source because it is the most common one we see, but the shape of the work is the same for JanusGraph, Cosmos DB Gremlin API, and TigerGraph.
Step 0 — decide what actually moves
Before any export, inventory the source. For Neptune, the property-graph model is TinkerPop: vertices and edges with labels and properties, plus a string ID on everything. For Neo4j you get labels (multiple per node), relationship types (exactly one per relationship), properties, and an internal element ID you should never persist as a business key.
Three decisions to make on paper first:
- ID strategy. Neptune's vertex IDs are user-supplied strings. Carry them across as a real property (
legacy_id) with a uniqueness constraint. Do not try to preserve internal IDs — you cannot, and code that depends on them is code you need to find now rather than during cutover. - Multi-label nodes. TinkerPop allows one label per vertex. Teams work around that with a
typeproperty orPerson::Employeecomposite labels. Migration is the moment to turn those into real Neo4j labels. - Edge properties and multiplicity. Neptune permits parallel edges of the same label between the same pair. Decide per relationship type whether that is meaningful (repeated events: keep them) or accidental (duplicated loads: merge them).
Write these down as a mapping table — source label → target labels, source edge label → relationship type, property renames, type coercions. Every later step reads from that table.
Step 1 — profile the source graph
You need counts before you can validate after. In Gremlin:
// vertex counts by label
g.V().groupCount().by(label)
// edge counts by label
g.E().groupCount().by(label)
// property keys present on a label (sample, not exhaustive)
g.V().hasLabel('Customer').limit(1000).properties().key().dedup().toList()
// degree outliers — future supernodes
g.V().project('id','deg').by(id).by(bothE().count()).order().by('deg', desc).limit(20)
Save that output. It is your acceptance test.
Step 2 — export
Neptune's bulk export path is the neptune-export tool, which writes CSV in the Neptune bulk-loader format (which is close to, but not the same as, Neo4j's import CSV format):
java -jar neptune-export.jar export-pg \
-e my-cluster.cluster-abc123.us-east-1.neptune.amazonaws.com \
-d ./export --format csv --concurrency 8 \
--clone-cluster
--clone-cluster exports from a clone so you do not saturate the production instance. For JanusGraph the equivalent is a Spark/OLAP export or g.io().write() to GraphSON; for TigerGraph, gsql export jobs to CSV.
You get two families of files: vertices/<Label>.csv with ~id,~label,prop:type,... headers, and edges/<Label>.csv with ~id,~from,~to,~label,....
Step 3 — reshape headers for neo4j-admin import
neo4j-admin database import wants its own header syntax: :ID, :LABEL, :START_ID, :END_ID, :TYPE, and typed property columns like since:datetime or scores:double[]. A small script does the rewrite; the mapping is mechanical:
| Neptune | Neo4j |
|---|---|
~id | legacy_id:ID(Customer) |
~label | :LABEL |
name:String | name |
age:Int | age:long |
joined:Date | joined:datetime |
~from / ~to | :START_ID(Customer) / :END_ID(Order) |
edge ~label | :TYPE |
Use ID spaces (:ID(Customer)) when different source labels can share an ID value; without them a collision silently links the wrong nodes.
Then run the offline import against a stopped database:
neo4j-admin database import full neo4j \
--nodes=Customer=import/headers/customer.csv,import/vertices/Customer.*.csv \
--nodes=Order=import/headers/order.csv,import/vertices/Order.*.csv \
--relationships=PLACED=import/headers/placed.csv,import/edges/PLACED.*.csv \
--id-type=string \
--skip-bad-relationships=true \
--bad-tolerance=1000 \
--report-file=import.report \
--overwrite-destination=true
Read import.report before you celebrate. Skipped relationships mean dangling endpoints in the source — usually deletes that never cascaded. Each one is a data-quality finding to hand back to the source team, not a number to shrug at.
For graphs under roughly 10 million nodes, or for incremental top-ups later, LOAD CSV with CALL { ... } IN TRANSACTIONS is simpler and runs against a live database. Our relational import tutorial covers the batching patterns; they apply unchanged here.
Step 4 — constraints and indexes, in the right order
Bulk import does not create indexes. Create them immediately afterwards, starting with the uniqueness constraints that protect the legacy IDs:
CREATE CONSTRAINT customer_legacy_id IF NOT EXISTS
FOR (c:Customer) REQUIRE c.legacy_id IS UNIQUE;
CREATE CONSTRAINT order_legacy_id IF NOT EXISTS
FOR (o:Order) REQUIRE o.legacy_id IS UNIQUE;
CREATE INDEX order_placed_at IF NOT EXISTS
FOR (o:Order) ON (o.placed_at);
CALL db.awaitIndexes(600);
Now apply the multi-label decisions from Step 0:
MATCH (n:Person)
WHERE n.type = 'employee'
SET n:Employee
REMOVE n.type;
And collapse accidental parallel edges where you decided to:
MATCH (a:Customer)-[r:PLACED]->(b:Order)
WITH a, b, collect(r) AS rels
WHERE size(rels) > 1
FOREACH (r IN rels[1..] | DELETE r);
Run that one in batches on a large graph.
Step 5 — translate the queries
This is the real work; expect it to be 60–70% of the effort. Gremlin is imperative traversal, Cypher is declarative pattern matching, and a one-to-one transliteration produces slow, unreadable Cypher. Translate intent, not steps.
Simple lookup
g.V().has('Customer','email','a@example.com').valueMap()
MATCH (c:Customer {email: 'a@example.com'}) RETURN c;
Two-hop traversal with a filter
g.V().has('Customer','legacy_id','c-1')
.out('PLACED').has('status','shipped')
.out('CONTAINS').values('sku')
MATCH (:Customer {legacy_id: 'c-1'})-[:PLACED]->(o:Order {status: 'shipped'})
-[:CONTAINS]->(p:Product)
RETURN p.sku;
Variable-length paths. Gremlin's repeat().times() / until() maps to Cypher quantified path patterns in Cypher 25, which are both clearer and better optimised than the old *1..5 syntax:
g.V().has('Part','legacy_id','p-1').repeat(out('REQUIRES')).times(5).emit().dedup()
MATCH (:Part {legacy_id: 'p-1'})-[:REQUIRES]->{1,5}(dep:Part)
RETURN DISTINCT dep;
See our write-up on quantified path patterns and SHORTEST k for the full syntax.
Aggregation
g.V().hasLabel('Order').group().by('status').by(count())
MATCH (o:Order) RETURN o.status AS status, count(*) AS orders ORDER BY orders DESC;
Shortest path. Gremlin needs a hand-rolled repeat().until().path(); Cypher has it built in:
MATCH p = SHORTEST 1 (a:Account {legacy_id: $from})-[:TRANSFER]-+(b:Account {legacy_id: $to})
RETURN p;
Two traps worth naming. First, Gremlin's dedup() sprinkled through a traversal usually exists to fight path explosion; in Cypher the equivalent is usually a WITH DISTINCT at the right point, or nothing at all, because pattern matching already deduplicates relationships within a MATCH. Second, if the source used Neptune's openCypher endpoint, the queries look portable but are not: Neptune's openCypher lacks APOC, GDS, and much of Cypher 25, and its id() semantics differ. Treat them as a starting draft, run each one through EXPLAIN, and check the plan.
Step 6 — validate
Automate this; a spreadsheet comparison will miss things.
// counts by label — compare against the Gremlin groupCount from Step 1
MATCH (n) UNWIND labels(n) AS l RETURN l, count(*) AS c ORDER BY l;
// counts by relationship type
MATCH ()-[r]->() RETURN type(r) AS t, count(*) AS c ORDER BY t;
// property coverage: any node missing a field that was mandatory upstream?
MATCH (c:Customer) WHERE c.email IS NULL RETURN count(*) AS missing_email;
// orphans that should not exist
MATCH (o:Order) WHERE NOT (o)<-[:PLACED]-(:Customer) RETURN count(*) AS orphan_orders;
// degree distribution — compare the top 20 against the source
MATCH (n) RETURN labels(n) AS labels, n.legacy_id AS id, count{ (n)--() } AS deg
ORDER BY deg DESC LIMIT 20;
Then run a behavioural check: take 20 real production queries, run them against both systems with the same inputs, and diff the result sets. Counts matching does not prove the semantics matched — a mis-mapped ID space will produce the right totals with the wrong edges.
Step 7 — cutover
Full-freeze migrations are fine if you can take a four-hour outage. Most clients cannot, so we use dual-write:
- Backfill. Bulk import a snapshot as above. Record the snapshot timestamp or the last change-stream position.
- Catch up. Replay changes since the snapshot. Neptune Streams gives you an ordered change log; consume it and apply idempotent
MERGEwrites keyed onlegacy_id. - Dual-write. Point the application at both stores for writes, Neptune still authoritative for reads. Watch for divergence with a nightly count-and-checksum job.
- Shadow reads. Send a percentage of read traffic to Neo4j and compare responses off the hot path. This catches translated queries that return subtly different orderings or null handling.
- Flip reads, one endpoint at a time, starting with the least critical.
- Stop dual-write after a defined soak period — a week is typical — and keep the source readable but frozen for another month.
Writing every step idempotently is what makes this safe:
UNWIND $rows AS row
MERGE (c:Customer {legacy_id: row.id})
ON CREATE SET c.created_at = datetime()
SET c += row.props, c.updated_at = datetime();
Step 8 — the things you can now do that you could not before
Worth putting in the project close-out, because it justifies the spend: Graph Data Science algorithms over the whole graph, vector indexes and GraphRAG retrieval on the same data, Bloom and NVL for exploration, native GraphQL, and a driver ecosystem with causal consistency guarantees. The point of the migration is not parity — it is the capability you buy on the other side.
A realistic schedule
For a 200M-relationship graph with roughly 80 distinct production queries, we plan around eight weeks: one week of assessment and mapping, one week of export and import tooling, three weeks of query translation and testing, one week of validation, and two weeks of dual-write soak and cutover. Smaller graphs compress to three or four; what does not compress is query translation, because it is bounded by how well the source queries are understood, not by data volume.
If you are scoping a migration off Neptune, JanusGraph, Cosmos DB, or TigerGraph, our Neo4j upgrade and migration services exist for exactly this, and our senior consultants have done the query-translation grind before. Get in touch with your source platform, graph size, and query count, and we will give you a realistic estimate.