Neo4j Index Fragmentation — UUID Bulk Imports 10x Slowdown
Index fragmentation from random UUID bulk inserts slowed Neo4j lookups 10x (20ms to 2000ms).
20+ years shipping high-throughput database systems. Written from production experience, not tutorials.
- ✓Deep production experience
- ✓Understanding of internals and trade-offs
- ✓Experience debugging complex systems
- Neo4j stores nodes and relationships as fixed-size records with direct pointers — no JOINs needed.
- Cypher is declarative; the planner picks a strategy based on cardinality estimates.
- B-tree indexes accelerate node lookups; full-text indexes for string searches.
- Missing or wrong indexes are the #1 cause of production slow queries.
- Memory allocation (page cache vs heap) directly impacts traversal speed.
- Always use PROFILE to see actual row counts — EXPLAIN guesses.
Neo4j's property graph model stores entities as nodes and connections as relationships. Each node can have any number of key-value properties. Relationships are directed, named, and can also have properties. This model maps directly to how your brain thinks about connected data — people, transactions, places, events — and the paths between them.
When you run MATCH (a:Person)-[:KNOWS]->(b:Person) RETURN a,b, Neo4j doesn't perform a JOIN. It follows a pointer from node a to the relationship record, then to node b. That's it. One memory dereference per hop.
Crucially, this means the cost of traversing a path is proportional to the number of hops, not the total graph size. That's why you can do 10-hop queries on a billion-node graph and get consistent sub-second response times. The trade-off? Writing data is more expensive because every relationship update must update multiple physical pointers. But for read-heavy graph workloads, it's a win.
Imagine every person in your school has a string connecting them to every friend, teacher, and club they belong to. A regular spreadsheet would need a massive lookup table just to find who knows who. Neo4j is the database that stores those strings directly — the connections ARE the data, not an afterthought. When you ask 'who are my friend's friends?', Neo4j just follows the strings instead of scanning millions of rows. That's the magic — no table scans, just pointer walks.
| Chrome | Firefox | Safari | Edge |
|---|---|---|---|
| ✓ | ✓ | ✓ | ✓ |
Most performance problems in production databases aren't caused by bad queries — they're caused by using the wrong data model. When your application's core questions are about relationships — fraud rings, recommendation engines, access control graphs, supply chain dependencies — a relational database forces you to JOIN your way through the problem. Those JOINs get exponentially slower as your dataset grows, not because your DBA made a mistake, but because the relational model was never designed for highly connected data.
Neo4j solves this with a property graph model where relationships are first-class, physically stored citizens. Unlike a relational database that must compute relationships at query time via JOINs, Neo4j pre-materializes every relationship as a pointer in storage. Traversing a million-hop graph takes the same time per hop whether your database has 100 nodes or 100 billion — a property called index-free adjacency. This is the core architectural decision that makes Neo4j structurally different from every relational or document database you've used.
By the end of this article you'll understand how Neo4j stores data on disk, how Cypher queries are planned and executed, which index types to choose for different access patterns, where the real performance cliffs are in production, and the gotchas that routinely bite engineers who come from a relational background. You'll walk away able to design a graph schema, write production-quality Cypher, and explain Neo4j's internal architecture to an interviewer or a skeptical CTO.
Here's the thing: if you're migrating from PostgreSQL, you'll find Cypher's syntax refreshingly different and the index-free adjacency a game-changer for deep traversals.
What is Neo4j Graph Database Basics?
Neo4j's property graph model stores entities as nodes and connections as relationships. Each node can have any number of key-value properties. Relationships are directed, named, and can also have properties. This model maps directly to how your brain thinks about connected data — people, transactions, places, events — and the paths between them.
When you run MATCH (a:Person)-[:KNOWS]->(b:Person) RETURN a,b, Neo4j doesn't perform a JOIN. It follows a pointer from node a to the relationship record, then to node b. That's it. One memory dereference per hop.
Crucially, this means the cost of traversing a path is proportional to the number of hops, not the total graph size. That's why you can do 10-hop queries on a billion-node graph and get consistent sub-second response times. The trade-off? Writing data is more expensive because every relationship update must update multiple physical pointers. But for read-heavy graph workloads, it's a win.
// TheCodeForge — Neo4j Graph Database Basics example // Always use meaningful names, not x or n public class ForgeExample { public static void main(String[] args) { String topic = "Neo4j Graph Database Basics"; System.out.println("Learning: " + topic + " 🔥"); } }
- Start node: 15 bytes, points to first relationship and first property.
- Relationship: 34 bytes, includes type ID, next/prev for both directions.
- Property chain: dynamic, each property record ~41 bytes plus key/value size.
- Reading one relationship = one disk page (if cached, one memory access).
- In a relational DB, one join = index lookup + B-tree traversal (multiple pages).
Neo4j Storage Internals: How Nodes and Relationships Live on Disk
Neo4j's physical storage model is the foundation of its speed. Each node is stored as a fixed-size record (15 bytes for the node itself, plus property chain pointers). Relationships are also fixed-size records (34 bytes) with start node ID, end node ID, relationship type, and pointers to previous/next relationship for both nodes. This is the 'index-free adjacency' — from any node you can walk all its relationships by following in-memory pointers, not hash lookups. The property chain links to a separate property store where key-value pairs are stored as dynamic records.
This matters in production: a traversal of 1,000 relationships reads exactly 1,000 relationship records, regardless of total graph size. That's why graph queries stay fast as data grows — the cost per hop is constant. The downside? Storage is rigid. Every node occupies the same fixed-size slot even if it has many properties (the rest go to overflow). Plan your property layout to avoid overflow chains that add extra reads.
A common trap: storing an array of 10,000 IDs on a single node forces the property chain to span many overflow records. Each overflow read costs a disk I/O (or page cache miss). That one 'convenient' property can turn a 10ms traversal into a 500ms crawl.
package io.thecodeforge.neo4j; import org.neo4j.driver.*; public class NodeCreation { public static void main(String[] args) { try (Driver driver = GraphDatabase.driver("bolt://localhost:7687", AuthTokens.basic("neo4j", "password"))) { try (Session session = driver.session()) { session.run("CREATE (u:User {name: $name, email: $email})", Values.parameters("name", "Alice", "email", "alice@corp.com")); } } } }
db.index.status(), consider redesigning the schema.db.index.status() periodically.Cypher Execution: How Neo4j Plans and Runs Your Queries
Cypher is a declarative query language, like SQL for graphs. When you send a Cypher query, three steps happen: parsing (syntax tree), semantic analysis (type/scope checking), and query planning. The planner reads the AST and builds a set of possible execution plans using graph statistics — label counts, degree distributions, index selectivity — to estimate cost. It picks the cheapest plan (by default). The plan is a tree of operators like NodeByLabelScan, NodeIndexScan, ExpandAll, Filter, Projection.
The planner uses a cost model based on cardinality estimates from stored statistics (updated periodically or by calling db.stats.collect()). If statistics are stale, the planner may pick a terrible strategy. For example, if it thinks a label has 100 nodes but it actually has 10 million, scanning that label becomes catastrophic.
Execution happens via an interpreted pipeline (default) or an experimental compiled runtime (faster but more memory). In production, use PROFILE to compare estimated vs actual rows. A 10x mismatch means stale stats or a bad query shape.
Here's a common trap: the planner cannot see correlations between properties. So WHERE n.city = 'Berlin' AND n.status = 'active' will multiply selectivities even if all active users are in Berlin. That leads to underestimates.
// Step 1: See the plan without running (EXPLAIN) EXPLAIN MATCH (u:User {email: 'alice@corp.com'}) RETURN u // Step 2: Execute and get actual row counts (PROFILE) PROFILE MATCH (u:User {email: 'alice@corp.com'}) RETURN u // Output of PROFILE shows: // +--------------+----------------+---------+-----------+----------------+ // | Operator | Estimated Rows | Rows | DB Hits | Memory (Bytes)| // +--------------+----------------+---------+-----------+----------------+ // | NodeIndexSeek| 1 | 1 | 2 | 10 | // ...
MATCH (u:User {city: 'Berlin', status: 'active'}), it multiplies selectivity (e.g., 0.1 * 0.2 = 0.02) even if all active users are in Berlin. This leads to underestimates and bad index choices.
Fix: break such queries into two hops, or manually force index usage with USING INDEX.db.stats.collect('ALL') after any bulk write (import, large delete).USING INDEX hint to override planner.Indexes in Neo4j: Types, Use Cases and How to Choose
Neo4j offers four index types: B-tree (default), Full-Text, Lookup, and Text (for CONTAINS). B-tree indexes are the workhorse — they support equality, range, and prefix searches. Full-text indexes use Lucene under the hood for tokenised queries. Lookup indexes speed up queries by label (NodeByLabelScan) or relationship type (RelationshipTypeScan). Text indexes are a specialised variant for CONTAINS matching.
You create indexes for labels-property pairs that appear in WHERE clauses. The index stores the property value in sorted order with a pointer to the node record. When you query with WHERE n.email = 'x', the planner can seek directly to the leaf page.
Composite indexes (multiple properties) are useful when queries always specify those properties together. Order matters: put the most selective property first. In production, monitor index size via CALL — a fragmented B-tree index can double the number of leaf pages, degrading reads.db.indexes()
// B-tree index (default) CREATE INDEX user_email_idx FOR (u:User) ON (u.email); // Composite index — put high-selectivity column first CREATE INDEX user_city_status_idx FOR (u:User) ON (u.city, u.status); // Full-text index for string searching CREATE FULLTEXT INDEX user_name_ft FOR (u:User) ON EACH [u.name]; // Text index for CONTANS (faster than full-text for exact substring) CREATE TEXT INDEX user_bio_text FOR (u:User) ON (u.bio); // Check index status CALL db.indexes() YIELD name, state, type, labelsOrTypes, properties;
db.index.fulltext.awaitEventuallyConsistentIndexRefresh() before querying if consistency is critical.CALL db.indexes() to catch fragmentation.=) or range (<, >) on a propertyCONTAINS or ENDS WITH on a large string propertyProduction Performance Tuning: Memory, Cache, and Configuration
Neo4j runs on the JVM, so heap and garbage collection matter. Two critical memory pools: page cache (caches graph records from disk) and heap (query execution, transactions). The page cache should be large enough to fit your entire graph (or at least the hot set). Heap is for query results, transaction state, and JVM overhead.
dbms.memory.pagecache.size: set to 80% of available RAM for dedicated servers. Formula: graph store size * 1.2 (oversampling).dbms.memory.heap.max_size: default 512M is too low for any production workload. Start at 4GB and monitor GC withor JMX.db.tool.gc()dbms.memory.heap.initial_size: set equal to max to avoid startup jitter.dbms.tx_state.memory_max_size: cap per transaction to prevent runaway queries from OOMing the heap.
G1GC is the default and works well with large heaps. Watch for concurrent mode failures (increase heap or tune -XX:InitiatingHeapOccupancyPercent).
In production, use neo4j-admin memrec to get recommended memory settings based on your store size.
# TheCodeForge — Production Neo4j Memory Configuration # Example for a 32GB RAM server with 200GB store dbms.memory.heap.initial_size=4G dbms.memory.heap.max_size=4G dbms.memory.pagecache.size=20G dbms.tx_state.memory_max_size=512M dbms.memory.off_heap.max_size=2G # Prevent query results from consuming all heap dbms.memory.query_max_size=256M # G1GC tuning # Add to JAVA_OPTS: -XX:+UseG1GC -XX:MaxGCPauseMillis=100 -XX:G1HeapRegionSize=32m
free -m to check before deploying.-XX:InitiatingHeapOccupancyPercent (default 45).gcviewer or export via JMX to Prometheus.neo4j-admin memrec for baseline recommendations.perf stat -e major-faults,minor-faults to see if page cache is too small.dbms.memory.pagecache.warmup.enabled=true to load hot pages on startup.dbms.tx_state.memory_max_size, add LIMIT on queries, and consider splitting large traversals into batches.Common Production Gotchas: Mistakes That Sabotage Neo4j Performance
Even with perfect schema and indexes, several patterns routinely cause production pain:
- Accidental Cartesian Products: When a MATCH pattern matches multiple paths, the planner may generate a cross product. For example,
MATCH (a:User), (b:User)without a relationship returnsN*Nrows. Always verify with PROFILE — a huge DB Hits spike is the clue. - Unbounded Variable-Length Paths:
MATCH (x)-[]->(y)without a bound can traverse the entire graph, exhausting heap. Always specify a range:[1..5]. - Stale Statistics: Already discussed — but note that statistics are not automatically updated after DELETE operations. Schedule a periodic
db.stats.collect('ALL'). - Large Property Lists: Storing an array of 10,000 IDs on a node looks convenient but causes massive property record chains. Normalise into separate relationship-connected nodes.
- Over-indexing: Too many indexes increase write latency and page cache pressure. An index for every property is wasteful. Index only the predicates used in hot queries.
- Not using batch operations for large imports: Using separate
CREATEstatements for each node/relationship causes massive transaction overhead. UseUNWINDor theLOAD CSVcommand for bulk imports.
// Gotcha 1: Cartesian product (DON'T) MATCH (u:User), (p:Product) WHERE u.email = 'alice@corp.com' RETURN u, p // Fix: Add relationship MATCH (u:User)-[:BOUGHT]->(p:Product) WHERE u.email = 'alice@corp.com' RETURN u, p // Gotcha 2: Unbounded var-length path (DON'T) MATCH (a)-[*]->(b) // Fix: Always specify max depth MATCH (a)-[*1..5]->(b) // Gotcha 3: Checking for index being used PROFILE MATCH (u:User {email: 'alice@corp.com'}) RETURN u // Look for NodeIndexSeek in plan // Gotcha 4: Slow bulk import (DON'T) CREATE (:User {name: 'Alice'}) CREATE (:User {name: 'Bob'}) // ... 10,000 separate CREATEs // Fix: Use UNWIND for batch insert UNWIND $users AS user CREATE (:User {name: user.name})
PROFILE with a single row output. Check for CartesianProduct or Apply operators that indicate unintended cross products. Also verify that the estimated rows match the actual rows within 2x.LIMIT in development to cap accidental explosions.db.index.status) and statistics (db.stats.retrieve). Rebuild index if needed.db.indexes() and correlation with query patterns.Graph Data Modeling Best Practices for Production
Good graph modeling is the difference between a smooth production system and a tangled mess. Three rules: avoid supernodes (nodes with tens of thousands of relationships), model actions as relationships not properties, and use labels to group nodes logically.
A supernode — like a 'Everyone' node connected to all users — kills traversal performance because ExpandAll on that node reads millions of relationships. Solution: break it into domain-specific star nodes or use index-assisted lookups instead of direct traversal.
Modeling tip: if you find yourself storing 'transaction_date' as a node property and then querying by time range, consider making 'Date' a node and connecting transactions to it. That turns a property filter into a relationship traversal, which is faster and more natural for time-series patterns.
Also, use existence constraints to enforce schema at the database level: CREATE CONSTRAINT FOR (u:User) REQUIRE u.email IS UNIQUE. This also creates an index — two birds with one stone.
// Avoid supernodes: don't connect all users to a single 'AllUsers' node // Instead, use label-based indexes // Good: enforce uniqueness and create index CREATE CONSTRAINT user_email_unique IF NOT EXISTS FOR (u:User) REQUIRE u.email IS UNIQUE; // Model time as a node for range traversals CREATE (d:Date {date: '2026-01-01'}) MATCH (t:Transaction {date: '2026-01-01'}) MERGE (t)-[:OCCURRED_ON]->(d); // Query: all transactions on specific date MATCH (t:Transaction)-[:OCCURRED_ON]->(d:Date {date: '2026-01-01'}) RETURN t
MATCH (n) RETURN labels(n), size((n)--()) as deg ORDER BY deg DESC LIMIT 10.Monitoring and Alerting for Neo4j Production
Even with a well-tuned graph, production incidents happen. You need visibility into four key areas: query performance, index health, memory pressure, and replication lag (if clustered).
For query performance, set up Prometheus exporters to capture neo4j_query_execution_time and neo4j_query_memory metrics. Create alerts for queries that exceed 500ms p99. Use CALL dbms.listQueries() to capture slow queries before they die.
Index health: monitor CALL for size/entries ratio. A ratio above 1.5 indicates fragmentation. Alert on that.db.index.status()
Memory: track page cache hit ratio (neo4j_page_cache_hits / total). A ratio below 99% means you need more page cache or a smaller hot set.
Log tailing: set up grep 'OUT_OF_MEMORY' /var/log/neo4j/debug.log to catch OOMs early. Use the HTTP API for real-time metrics: GET /db/manage/server/jmx/domain/org.neo4j/bean%3Aname%3DPageCache.
#!/bin/bash # TheCodeForge — Neo4j Monitoring Script # Capture key metrics every 60 seconds while true; do # Query performance: p99 latency echo "--- $(date) ---" >> /var/log/neo4j_monitor.log curl -s "http://localhost:7474/db/manage/server/jmx/domain/org.neo4j/bean%3Aname%3DQueryExecution" | jq '.beans[].queryExecutionTime.p99' >> /var/log/neo4j_monitor.log # Page cache hit ratio curl -s "http://localhost:7474/db/manage/server/jmx/domain/org.neo4j/bean%3Aname%3DPageCache" | jq '.beans[].hitRatio' >> /var/log/neo4j_monitor.log # Index fragmentation check (requires admin authentication) cypher-shell -u neo4j -p password "CALL db.index.status() YIELD index_name, size, num_entries WHERE size / num_entries > 1.5 RETURN index_name" >> /var/log/neo4j_monitor.log sleep 60 done
neo4j-admin check-consistency weekly to catch store corruption early.What the Hell Are Graph Databases (and Why You Should Care)
Relational databases are great for spreadsheets. Terrible for relationships. When you join seven tables to answer 'who sold what to whom in June,' you've already lost. Graph databases flip the model: relationships are first-class citizens, not afterthoughts computed at query time.
A graph stores nodes (entities) and edges (relationships). Both carry properties. Traversing relationships is index-free adjacency — each node physically points to its neighbors on disk. No JOINs, no expensive pointer chasing. You get constant-time traversal depth. That's why recommendation engines, fraud rings, and supply chain systems run on graphs.
Neo4j is the battle-tested leader. It's ACID-compliant, has a declarative query language (Cypher), and doesn't fall over when your dataset hits billions of nodes. If your data looks like a spiderweb of connections, a graph database is the right tool. If it looks like a CSV file, stick with Postgres.
// io.thecodeforge — database tutorial // Real fraud detection: show paths from flagged transaction to any // known bad actor within 3 hops MATCH path = (t:Transaction {id: 'TXN-489122'})-[*1..3]-(b:BadActor) RETURN path LIMIT 20;
Cypher Query Language Essentials: Stop Writing SQL, Start Walking Graphs
Cypher looks like ASCII art of the graph you're querying. That's intentional. You describe the pattern you want, Neo4j figures out how to fetch it efficiently. No more dragging through execution plans to understand why your six-table JOIN is killing the DB.
Nodes are parenthesized: (n:Person). Relationships are bracketed with arrows: -[r:KNOWS]->. You can bind variables, filter on properties, and traverse variable-length paths. The MATCH clause is your SELECT; RETURN is your output. WHERE, ORDER BY, and LIMIT work like you'd expect.
The killer feature: path patterns. MATCH (a:Person)-[:KNOWS*1..3]-(b:Person) finds everyone within three hops. In SQL, that's a recursive CTE with join explosion. In Cypher, it's one line. If you're building social feeds, recommendation engines, or hierarchy flatteners, Cypher slashes query time from minutes to milliseconds.
// io.thecodeforge — database tutorial // Find all employees reporting to a manager up to 4 levels deep MATCH (ceo:Employee {title: 'CEO'})<-[:REPORTS_TO*1..4]-(sub:Employee) RETURN sub.name, length(path) AS depth ORDER BY depth;
Connecting Neo4j From Python: The Production-Grade Pipeline
You're not running Cypher manually forever. You'll integrate Neo4j into Python applications for ETL, APIs, or analytics. The official neo4j driver is a synchronous/asynchronous Python client that speaks Bolt protocol — Neo4j's binary wire protocol. It handles connection pooling, transaction management, and automatic retries.
Always use parameterized queries. Never concatenate strings into Cypher. Injection attacks on graph databases can delete nodes, relationships, and entire subgraphs. The driver supports session.run() with parameters as a dict. Wrap writes in transactions using session.execute_write() to get ACID guarantees.
Don't open a new connection per request. Reuse a driver instance — it manages a pool under the hood. Set max_connection_lifetime to 1800 seconds to avoid stale sockets. And for god's sake, close the driver on application shutdown. Leaking connections to Neo4j in production is how you get paged at 3 AM.
// io.thecodeforge — database tutorial from neo4j import GraphDatabase driver = GraphDatabase.driver( "bolt://prod-neo4j-01.internal:7687", auth=("neo4j", get_secret()) ) with driver.session(database="orders") as session: result = session.run( """ MATCH (c:Customer {customer_id: $cid})-[:PLACED]->(o:Order) RETURN o.total, o.created_at ORDER BY o.created_at DESC LIMIT 10 """, cid="CUST-98765" ) for record in result: print(f"${record['o.total']} on {record['o.created_at']}") driver.close()
Why You Need a Graph Projection Layer Before Production
You don't query raw Neo4j storage in production. You query a projection. That's the dirty secret nobody tells you until your third incident call at 3 AM.
When you run a Cypher query, Neo4j doesn't scan disk. It materializes a subgraph into memory, applies filters, and then executes traversal logic. If your data model forces the engine to pull half the database into heap just to answer "who scored against whom?", you've already lost.
Architects who skip this step end up with 30-second response times on a 10 million node graph. The fix is simple: model your hot paths as explicit projections. Create relationship types that mirror your most frequent traversal patterns. Use Cypher's WITH clause to slice the graph before you explode it.
Your production graph isn't your query graph. Learn the difference or watch your latency burn.
// io.thecodeforge — database tutorial // Bad: full graph scan before filter MATCH (p:Player)-[:SCORED]->(g:Goal) WHERE g.minute < 10 RETURN p.name, count(g) AS early_goals; // Good: projection first, traversal second MATCH (p:Player) WHERE p.league = 'Premier League' WITH p MATCH (p)-[:SCORED]->(g:Goal) WHERE g.minute < 10 RETURN p.name, count(g) AS early_goals;
Relationship Direction Is Your Fastest Index — Use It or Lose It
Neo4j stores relationships as doubly linked lists. But here's the part that kills performance: traversal direction determines whether the engine walks an index or scans a heap.
When you query (a)-[r]->(b), Neo4j looks up node A, then follows the outgoing relationship chain. That's O(1) for node access, O(degree) for traversal. If you flip the direction to (a)<-[r]-(b), the engine has to scan all incoming relationships to node B. Same query, different cost. Many production graphs have fan-out ratios of 1:1000. In the wrong direction, you pay for all 1000.
The rule: align your relationship direction with your traversal cardinality. If you always ask "who scored this goal?", model (Goal)<-[:SCORED]-(Player) so you start at the goal and walk backward to the few players. Storing it the other way forces an inverse scan every time.
This is not theory. This is the difference between 5ms and 500ms on a hot path.
// io.thecodeforge — database tutorial // Fast: start at goal, walk to player MATCH (g:Goal {id: 'goal_441'})<-[r:SCORED]-(p:Player) RETURN p.name, r.minute; // Slow: scan all players to find one goal MATCH (p:Player)-[r:SCORED]->(g:Goal {id: 'goal_441'}) RETURN p.name, r.minute;
Index Fragmentation Slowed Read Queries 10x in a Recommendation Engine
CALL db.index.fulltext.awaitEventuallyConsistentIndexRefresh followed by CREATE INDEX ... IF NOT EXISTS after dropping and recreating. Then switched to sequential internal IDs for bulk loads by using db.ids.reuse_types_over_deleted_nodes configuration.- Index fragmentation happens silently — monitor index page density via
procedures.db.index.status() - Prefer sequential IDs (like auto-increment or timestamp-based) for bulk inserts to reduce fragmentation.
- Always rebuild indexes after large bulk loads, especially for high-selectivity properties used in lookups.
- Use PROFILE regularly — the query plan won't tell you about physical index health.
db.stats.retrieve('GRAPH COUNTS').dbms.memory.heap.max_size and dbms.memory.pagecache.size. For traversals, throttle with LIMIT and use UNWIND to batch. Check for accidental cartesian products in the query.CALL db.indexes(). Check if the predicate uses a function (e.g., toUpper) or if the type is wrong. Force index usage with USING INDEX as a temporary measure.CALL db.indexes() YIELD name, state, type WHERE state='ONLINE'CALL db.index.status('index_name') YIELD index_name, num_entries, sizeCALL db.stats.retrieve('GRAPH COUNTS')CALL db.stats.collect('GRAPH COUNTS')CALL db.stats.collect('ALL') to force full statistics updateCALL dbms.listQueries() YIELD queryId, query, elapsedTimeMillis, allocationBytesCALL dbms.killQuery(queryId)CALL db.labels() YIELD label WHERE size([index in db.indexes() WHERE index.labels[0]=label]) = 0EXPLAIN MATCH (n:Label) WHERE n.prop = 'value'CALL db.indexes() YIELD name, state, type, labelsOrTypes, propertiesPROFILE MATCH (n:Label) WHERE n.prop = 'value' RETURN n LIMIT 1| Concept | Use Case | Example |
|---|---|---|
| Neo4j Graph Database Basics | Core usage | See code above |
| Index-Free Adjacency | Fast graph traversal | 10k hops = 10k pointer dereferences |
| B-tree Index | Equality/range lookups | CREATE INDEX FOR (n:User) ON (n.email) |
| Full-Text Index | Tokenised search | CREATE FULLTEXT INDEX FOR (n:User) ON EACH [n.name] |
| Page Cache | Caching graph records | dbms.memory.pagecache.size=20G |
| File | Command / Code | Purpose |
|---|---|---|
| ForgeExample.java | public class ForgeExample { | What is Neo4j Graph Database Basics? |
| io | public class NodeCreation { | Neo4j Storage Internals |
| query_profile.cypher | EXPLAIN MATCH (u:User {email: 'alice@corp.com'}) RETURN u | Cypher Execution |
| create_indexes.cypher | CREATE INDEX user_email_idx FOR (u:User) ON (u.email); | Indexes in Neo4j |
| neo4j.conf | dbms.memory.heap.initial_size=4G | Production Performance Tuning |
| gotchas.cypher | MATCH (u:User), (p:Product) | Common Production Gotchas |
| io | CREATE CONSTRAINT user_email_unique IF NOT EXISTS FOR (u:User) REQUIRE u.email I... | Graph Data Modeling Best Practices for Production |
| monitor.sh | while true; do | Monitoring and Alerting for Neo4j Production |
| GraphModelInAction.sql | MATCH path = (t:Transaction {id: 'TXN-489122'})-[*1..3]-(b:BadActor) | What the Hell Are Graph Databases (and Why You Should Care) |
| CypherPathFinder.sql | MATCH (ceo:Employee {title: 'CEO'})<-[:REPORTS_TO*1..4]-(sub:Employee) | Cypher Query Language Essentials |
| PythonNeo4jConnector.sql | from neo4j import GraphDatabase | Connecting Neo4j From Python |
| GraphProjectionFix.sql | MATCH (p:Player)-[:SCORED]->(g:Goal) | Why You Need a Graph Projection Layer Before Production |
| DirectionMatters.sql | MATCH (g:Goal {id: 'goal_441'})<-[r:SCORED]-(p:Player) | Relationship Direction Is Your Fastest Index |
Key takeaways
Common mistakes to avoid
9 patternsMemorising syntax before understanding the concept
Skipping practice and only reading theory
Using `depends_on` style thinking in Cypher (expecting automatic index usage without explicit index hints)
USING INDEX in the query as a temporary hint, but fix the underlying issue (stale stats or missing index).Creating indexes on every property without considering query patterns
CALL db.indexes() and system logs. Drop indexes that are never used in WHERE clauses. Index only the predicates in your 10 most critical queries.Not limiting variable-length path ranges
[*1..5]. If unbounded is truly needed, use breadth-first traversal via shortestPath or allShortestPaths.Overlooking index fragmentation after bulk imports
CALL db.index.status() and compare size vs entries. If fragmentation is high, drop and recreate the index. Use sequential IDs for bulk loads.Not adjusting page cache when adding new data or increasing RAM
dbms.memory.pagecache.size accordingly. Use neo4j-admin memrec for recommendations.Ignoring supernodes during schema design
Not using batch operations for large imports
UNWIND with parameter arrays or LOAD CSV with periodic commit. Avoid iterating over individual CREATE statements in a loop.Interview Questions on This Topic
Explain how index-free adjacency works in Neo4j and why it matters for performance.
How do you debug a Cypher query that suddenly becomes slow in production?
CALL db.stats.collect('ALL') to refresh them. If the plan shows NodeByLabelScan when an index exists, check the predicate: indexes are only used for equality, range, IN, STARTS WITH—not for functions like toUpper() or substring. Also verify the index is ONLINE via CALL db.indexes(). For high-degree traversals, ensure variable-length paths have a bound. Finally, isolate the query's input parameters—sometimes a parameter value that matches many nodes causes a scan to become expensive, and an index hint (USING INDEX) can help until the query is rewritten.What is the difference between B-tree and Full-Text indexes in Neo4j? When would you use each?
WHERE n.email = 'x' or WHERE n.age > 30. Full-Text indexes use Apache Lucene for tokenised string searching. They support fuzzy matching, phrase queries, and scoring. Use Full-Text when you need to search across multiple properties (e.g., name and bio) and rank results by relevance. Full-Text indexes are eventually consistent by default, so they may lag behind writes. B-tree indexes are always consistent. The trade-off: B-tree for exact/range, Full-Text for search.How does Neo4j handle concurrent writes? Explain the locking strategy.
RETRY logic in your application to handle lock-acquisition failures gracefully. For high-contention scenarios, consider redesigning the graph to spread writes across different nodes.What is a node in Neo4j and how is it different from a row in a relational table?
Explain how Neo4j's page cache interacts with the operating system's page cache and why this matters for performance tuning.
neo4j-admin memrec for baseline, then monitor page fault rates via perf.How do you detect and fix a supernode in a production graph?
MATCH (n) RETURN labels(n), size((n)--()) as deg ORDER BY deg DESC LIMIT 10 to find high-degree nodes. If any node has more than 10,000 relationships, it's a supernode. Check whether all those relationships are necessary. Often the fix is to break the node into smaller domain-specific nodes (e.g., replace a single 'Category' node with several 'Category' nodes connected hierarchically) or to use index-based lookups. Another approach is to add relationship types to filter early, e.g., MATCH (n)-[:SPECIFIC_TYPE]->(m) instead of -[]->. In extreme cases, denormalise the graph by duplicating data across multiple nodes to spread the degree.How do you handle read replicas in a Neo4j cluster?
Frequently Asked Questions
No. Neo4j excels at graph traversals and relationship-heavy queries. For simple CRUD operations on individual entities (like fetching a single user by ID), a relational database can be just as fast or faster due to simpler storage and indexing. Use a graph database when your app's core value comes from the connections between entities—recommendations, fraud detection, access control, supply chain paths.
When your WHERE clause always includes two or more properties on the same label—for example, MATCH (u:User {city: 'Berlin', status: 'active'}). Composite indexes are more selective than single-property indexes; the planner can seek directly to the segment with both values. Order matters: put the most selective property first. If queries sometimes omit one of the properties, you may still benefit from a single-property index on the always-present one.
First, add a LIMIT clause to cap the result set size. Second, ensure the query uses indexes to avoid scanning millions of nodes. Third, use PROFILE to check for accidental Cartesian products. If you truly need to process large sets, split the query into batches using SKIP and LIMIT in a loop, or use APOC procedures like apoc.periodic.commit for batch processing. Also reduce dbms.tx_state.memory_max_size to prevent one query from exhausting the heap.
For online backups, use the neo4j-admin backup command (Enterprise Edition) or the neo4j-admin dump to create a logical dump. For offline backups, stop the database and copy the entire data directory. Always test your backup restoration process. For cloud environments, consider snapshotting the data volume after flushing the page cache.
Yes, Neo4j offers clustering via Causal Clustering (Enterprise Edition) which provides read replicas and high availability. The cluster uses Raft for consensus on writes. Read replicas can scale horizontally for read-heavy workloads. However, write throughput is limited by the leader's capacity. For massive write scalability, consider sharding via federation (custom) or using a different graph system designed for horizontal writes.
Symptoms include queries that hang on traversal or show millions of DB hits on ExpandAll. Run MATCH (n) RETURN labels(n), size((n)--()) as deg ORDER BY deg DESC LIMIT 10 to identify high-degree nodes. Fix by restructuring: break the supernode into multiple nodes (e.g., time-partitioned nodes), use more specific relationship types, or replace direct relationships with index-assisted lookups. In some cases, adding a LIMIT to queries can prevent catastrophic memory usage while you remodel.
Key metrics: page cache hit ratio (should be >99%), p99 query latency (<500ms for indexed lookups), index fragmentation (size/entries <1.5), heap memory usage (<80% of max), and replication lag (<10s for clusters). Use the JMX API with Prometheus/Grafana, or set up Neo4j's built-in metrics reporter. Also tail the debug.log for OOMs and long GC pauses.
EXPLAIN shows the estimated execution plan without running the query. It uses the stored statistics to guess how many rows each operator will process. PROFILE actually runs the query and returns the plan with actual row counts, DB hits, and memory usage. Always use PROFILE in testing — it reveals the real cost. If estimated vs actual rows differ by more than 10x, your statistics are stale.
20+ years shipping high-throughput database systems. Written from production experience, not tutorials.
That's NoSQL. Mark it forged?
9 min read · try the examples if you haven't