In 2022, we faced a decision: our Neo4j bill was growing faster than our features. We were storing millions of customer touchpoints in a graph database, and as that graph scaled, licensing costs scaled with it. By early 2023, we had migrated to AWS Neptune and saved $25K annually while maintaining the same reliability and performance.
This is the technical story of what we did, what went wrong, and what we'd do differently.
The Cost Problem
Neo4j's pricing model is based on instance size (RAM). As our customer touchpoint graph grew, we had to continuously upgrade:
- Early 2022: $3K/month (50GB graph)
- Mid 2022: $8K/month (100GB graph, upgraded for performance)
- Late 2022: On track to hit $15K/month (150GB graph, HA cluster needed)
The trajectory was clear: costs would keep accelerating. We needed an alternative.
Why AWS Neptune?
Neptune made sense because:
- Managed service: AWS handles backups, failover, monitoring. No ops burden
- Pay-per-instance: We pay for compute we use, not licensing
- HA built-in: High availability costs nothing extra
- Gremlin support: We could migrate our Cypher queries (not trivial, but doable)
The Migration Process (2022-2023)
Phase 1: Assessment (Week 1)
We started by understanding what we were migrating:
- Graph size: 150GB (100 million nodes, 300 million edges)
- Access patterns: 95% reads, 5% writes (mostly batch updates)
- Query complexity: Traversals up to 6 hops, pattern matching
- SLA: 99.9% uptime, <1 second query latency for customer-facing queries
Phase 2: Testing (Weeks 2-3)
We launched a Neptune test instance and:
- Exported 10% of our Neo4j graph to CSV
- Transformed the data (Neo4j properties → Gremlin format)
- Loaded into Neptune test instance
- Ported our 30+ Cypher queries to Gremlin
- Ran side-by-side queries, compared results exactly
Gotcha we found: Gremlin handles graph traversals differently than Cypher. Some queries needed complete rewrites.
Phase 3: Full Data Migration (Week 4)
Once we were confident, we migrated the full graph:
Step 1: Stop writes to Neo4j
Step 2: Export all nodes and edges to S3 (CSV format)
Step 3: Validate export (row counts, checksums)
Step 4: Neptune bulk load from S3 (~2 hours for 150GB)
Step 5: Run validation queries on Neptune
Step 6: Compare customer touchpoint counts (must match exactly)
Migration took one weekend. Zero data loss.
Phase 4: Application Code Migration (Weeks 5-6)
The application layer needed updates:
- Swapped Neo4j driver for Neptune/Gremlin driver
- Rewrote critical queries (about 10 required significant changes)
- Added retry logic (Neptune occasionally times out on huge traversals)
- Tuned connection pooling for Gremlin
Phase 5: Parallel Running (Weeks 7-8)
We ran both databases simultaneously for 2 weeks:
- Writes went to both Neo4j and Neptune
- Reads came from Neo4j (production)
- Gremlin queries ran in the background, results compared
- Any discrepancies were logged and resolved
This gave us huge confidence. We found (and fixed) edge cases that would have broken production.
Phase 6: Cutover (Friday, Week 9)
On Friday evening, we switched:
- Verified Neptune had latest data
- Stopped writes to Neo4j (read-only mode)
- Updated application to read from Neptune
- Monitored error rates and latency for 30 minutes (heightened alerting)
- Kept Neo4j running as a fallback (for 2 weeks, just in case)
Cutover was clean. No incidents. We turned off Neo4j after 2 weeks of successful production use.
Results
Cost Savings
- Neo4j: $8K/month × 12 = $96K/year
- Neptune: $8K/year (r5.2xlarge instance)
- Migration effort: ~$10K (engineer time, testing)
- Payback period: 1.4 months
- Annual savings: $25K (after the first year)
Performance & Reliability
- Query latency: Similar to Neo4j (some queries 5-10% slower, but within SLA)
- Uptime: 100% (vs. 99.5% with Neo4j manual management)
- Backups: Automatic, point-in-time recovery
- Scaling: Read replicas added in minutes (vs. hours with Neo4j cluster management)
What Went Wrong (And How We Fixed It)
Issue 1: Gremlin Query Performance
Problem: One critical query (finding customer relationships 3+ hops away) was 30% slower in Gremlin.
Solution: Added materialized paths (pre-computed common traversals, cached in Redis). Query latency dropped below SLA.
Issue 2: Timeout on Large Traversals
Problem: Queries on the deepest parts of the graph (6+ hops) would occasionally timeout.
Solution: Increased Neptune query timeout, added pagination (break large traversals into smaller queries).
Issue 3: Data Type Mismatches
Problem: Some Neo4j properties (dates, IDs) had subtle type differences when loaded into Neptune.
Solution: Explicit type casting during data transformation, validation queries before cutover.
What We'd Do Differently
- Gremlin training upfront: We learned Gremlin on the job. Taking a course first would have saved time
- Query optimization during testing: We optimized queries after cutover. Doing it before would have removed risk
- Longer parallel running: 2 weeks was good, but 1 month would have caught more edge cases
- Document all query changes: We should have recorded exactly why each Cypher→Gremlin change was made
Lessons Learned
1. Managed Services Win Over Time
Neptune requires zero ops effort. Neo4j required constant tuning. The TCO gap is huge.
2. Data Validation is Non-Negotiable
We validated customer touchpoint counts at every stage. This caught issues before they affected customers.
3. Parallel Running Prevents Disasters
Running both systems let us find bugs in isolation. Cutover was boring—the best kind.
4. Graph Query Languages Are Not Interchangeable
Cypher and Gremlin express the same problems differently. You can't just translate syntax; you need to rethink logic.
Conclusion
The Neo4j to Neptune migration was one of the best ROI projects we did. $25K annual savings, zero data loss, better uptime, less operational burden.
If you're running Neo4j and your license costs are climbing, Neptune is worth serious evaluation. The migration is non-trivial but absolutely doable with the right process.