Choosing the Right Neo4j Index Type for Your Query Patterns
A decision guide for selecting Neo4j 5.x index types (BTREE, TEXT, POINT, FULLTEXT, LOOKUP) based on query predicates, data cardinality, and consistency requirements. Includes a comparison table, trade-off analysis, and concrete validation steps with Cypher commands.
07 Jul 2026, 20:24 UTC

The Decision: Match Index Type to Access Pattern
Neo4j 5.x offers five index types, each optimized for a distinct query shape. Picking the wrong one wastes disk, slows writes, and leaves queries on full scans. The decision hinges on three factors: the predicate operators in your WHERE clauses, the cardinality and data type of the indexed property, and whether you need ACID consistency or can tolerate eventual consistency for text search.
Quick Comparison
| Index Type | Best For | Supported Predicates | Write Overhead | Consistency | Order By Support |
|---|---|---|---|---|---|
| BTREE (RANGE) | High-cardinality properties, equality, range, prefix, sorting | =, <, >, <=, >=, IN, STARTS WITH | ~10-20% per index | ACID | Yes |
| TEXT | Case-insensitive/normalized string search (CONTAINS, ENDS WITH, regex) | CONTAINS, ENDS WITH, =~ (regex) | ~30-50%, 2-3x property size on disk | ACID | No |
| POINT | Spatial queries: distance, bounding box, polygon | point.distance(), point.withinBBox() | ~10-20% | ACID | No |
| FULLTEXT (Lucene) | Phrase, fuzzy, wildcard, boolean text search | Via db.index.fulltext.queryNodes | ~30-50%, async population | Eventual | No |
| LOOKUP (Token) | Label/relationship existence, COUNT(*) | Implicit (label/type existence) | Negligible | ACID | N/A |
Trade-offs in Detail
BTREE: Default for Ordered Access
BTREE indexes (provider native-btree-1.0) handle equality, range, and prefix matches. They also satisfy ORDER BY without an in-memory sort. Use them on properties like createdAt, email, or accountId where cardinality is high and queries filter or sort.
Composite BTREE indexes follow the left-prefix rule: an index on (a, b, c) serves predicates on a, (a, b), or (a, b, c) but not b alone. Order properties by selectivity (highest first) to maximize pruning.
TEXT: Normalized String Matching Without Full-Text Search
TEXT indexes store normalized (lowercased, diacritic-folded) forms of string properties. They enable CONTAINS, ENDS WITH, and regex matches that BTREE cannot accelerate. The cost: write latency increases 30-50%, and disk usage grows 2-3x the raw property size. They do not support ORDER BY; queries needing sort on a TEXT-indexed property fall back to an expensive in-memory sort.
POINT: Spatial Queries Only
POINT indexes use an R-tree and require a POINT property with a declared CRS (WGS-84 or Cartesian). They accelerate point.distance() and point.withinBBox() predicates. Mixing CRS types in the same index throws an error at creation time. Write overhead is comparable to BTREE.
FULLTEXT: Lucene-Powered Search with Eventual Consistency
FULLTEXT indexes are backed by Lucene and exposed via db.index.fulltext.queryNodes. They support phrase, fuzzy, wildcard, and boolean queries. Population is asynchronous; a read immediately after a write may not see the new data. Call db.index.fulltext.awaitIndexing() if you need synchronous visibility. They are not ACID with the core transaction log.
LOOKUP: Automatic, Zero-Config
LOOKUP indexes are created automatically for every label and relationship type. They speed up COUNT(*) and existence checks. You cannot drop or customize them, and their overhead is negligible.
Concrete Implementation: Validating Index Usage
Suppose you have a :User node with properties email (string, high cardinality), fullName (string, needs case-insensitive substring search), and location (POINT, WGS-84). You need to optimize three query patterns:
- Exact email lookup:
MATCH (u:User {email: $email}) RETURN u - Case-insensitive name search:
MATCH (u:User) WHERE u.fullName CONTAINS $term RETURN u - Users within 5 km of a coordinate:
MATCH (u:User) WHERE point.distance(u.location, $center) < 5000 RETURN u
Step 1: Create Indexes
Run these Cypher statements in a Neo4j Browser or cypher-shell with schema-write privileges (admin or DBMS INDEX MANAGEMENT role). Use IF NOT EXISTS to avoid errors on re-runs; Neo4j 5.13+ performs online schema changes without exclusive locks.
CREATE INDEX user_email IF NOT EXISTS FOR (u:User) ON (u.email) OPTIONS {indexProvider: 'native-btree-1.0'};
CREATE TEXT INDEX user_fullname IF NOT EXISTS FOR (u:User) ON (u.fullName);
CREATE POINT INDEX user_location IF NOT EXISTS FOR (u:User) ON (u.location) OPTIONS {crs: 'wgs-84'};
Risk: Creating indexes on large existing datasets triggers a background population that consumes I/O and CPU. Monitor populationPercent via CALL db.indexes() before running latency-sensitive workloads.
Step 2: Verify Plan Operators with EXPLAIN
Run EXPLAIN (not PROFILE) for each query pattern. Look for NodeIndexSeek (point lookup) or NodeIndexScan (range/scan). An AllNodesScan with estimatedRows equal to total :User nodes means the index is not being used.
EXPLAIN MATCH (u:User) WHERE u.email = '[contact removed]' RETURN u;
EXPLAIN MATCH (u:User) WHERE u.fullName CONTAINS 'smith' RETURN u;
EXPLAIN MATCH (u:User) WHERE point.distance(u.location, point({latitude: 37.77, longitude: -122.42})) < 5000 RETURN u;
Expected operators: first query shows NodeIndexSeek on user_email; second shows NodeIndexScan on user_fullname; third shows NodeIndexSeek with a spatial predicate on user_location.
Step 3: Measure Write Impact
Before deploying to production, benchmark write throughput. In a test environment, run a representative write workload (e.g., :auto LOAD CSV or a custom script) before and after index creation. Capture latency with apoc.perf.stats() or a client-side timer. Expect 10-20% overhead for BTREE/POINT, 30-50% for TEXT.
// Example: time 10k User creates
:auto LOAD CSV WITH HEADERS FROM 'file:///users.csv' AS row
CREATE (:User {email: row.email, fullName: row.name, location: point({latitude: toFloat(row.lat), longitude: toFloat(row.lon), crs: 'wgs-84'})});
Step 4: Monitor Index Size and Planner Health
Run CALL db.indexes() periodically. Watch size (bytes) and populationPercent (should be 100). More than ~100 indexes on the same label can degrade the Cypher planner due to heap pressure; monitor dbms.memory.heap.max_size.
CALL db.indexes() YIELD name, indexProvider, labelsOrTypes, properties, state, populationPercent, size
RETURN name, indexProvider, labelsOrTypes, properties, state, populationPercent, size;
Limitations and Gotchas
- TEXT indexes cannot satisfy ORDER BY. If you need sorted results on a TEXT-indexed property, add a separate BTREE index on that property or accept the in-memory sort cost.
- FULLTEXT indexes are eventually consistent. Do not rely on them for read-your-writes scenarios without calling
awaitIndexing(). - POINT indexes require matching CRS. A WGS-84 index cannot index Cartesian points and vice versa.
- Dropping large BTREE indexes in Neo4j < 5.13 takes an exclusive schema lock on the label. Plan maintenance windows or upgrade to 5.13+ for online drops.
- Composite BTREE left-prefix rule is strict:
(a,b)does not help a predicate onbalone.
Verification Checklist
CALL db.indexes()shows all three indexes withstate: ONLINEandpopulationPercent: 100.EXPLAINfor each query pattern showsNodeIndexSeekorNodeIndexScan(noAllNodesScan).- Write latency increase stays within expected bounds (10-20% BTREE/POINT, 30-50% TEXT).
- FULLTEXT sync lag tested: write a node, immediately call
db.index.fulltext.queryNodes; if count is zero, wait or callawaitIndexing().
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.