Graph Partitioning Strategies
Partitioning a Neo4j graph is a design decision about where boundaries live, not a storage-sharding operation you delegate to the engine. Because Neo4j’s native pointer-based adjacency storage optimizes for local traversal, there is no automatic sharding layer that will keep one tenant’s writes from contending with another’s, or stop a MATCH from wandering out of its intended subgraph. Those guarantees have to be engineered at the schema, query-routing, and transaction layers. This page defines how to draw partition boundaries that the Cypher planner can exploit for index pruning, that map cleanly onto role-based access control, and that survive versioned schema migrations — so platform teams can scale ingestion and analytical workloads without cross-domain lock contention or unpredictable traversal cost.
Prerequisite concepts
Partitioning composes the other schema decisions in this section; read these first if any are unfamiliar:
Conceptual model
A partition in Neo4j is a logical region of a single graph (or a dedicated database) whose members share a label or a database boundary, and between which traversals are deliberately constrained. The planner uses labels as its routing and index-selection substrate, so partition-scoped labels let it eliminate irrelevant subgraphs before execution begins. The diagram below shows two label-scoped partitions inside one database, the planner pruning by label, and a cross-partition edge that the design forbids.
Design rules & decision matrix
Partition keys are first-class schema constructs, not incidental properties. Encode them where the planner can see them — in labels and node keys — rather than burying a partitionId property that only a WHERE clause can reach. The matrix below maps each isolation goal to a concrete boundary mechanism and its enforcement point.
| Isolation goal | Boundary mechanism | Enforced at | Primary risk if skipped |
|---|---|---|---|
| Tenant scoping | Dedicated database per tenant, or tenantId node key |
RBAC + composite index | Cross-tenant query leakage |
| Domain routing | Partition-scoped secondary label (:Customer:Active) |
Label taxonomy + planner pruning | Full-store scans, planner cardinality error |
| Write safety | Batch-scoped transactions per partition | Transaction functions | Lock escalation on shared super-nodes |
| Analytical isolation | Named subgraph / composite label filter | Query routing layer | Unbounded cross-partition traversal |
| Auditability | Immutable provenance properties (_partitionVersion) |
Ingestion-side validation | Non-deterministic lineage queries |
Three rules follow directly from that matrix:
- Prefer a database boundary for hard isolation; prefer a label boundary for soft, high-cardinality domain routing. A per-tenant database gives you RBAC-enforced isolation at the cost of connection routing; a secondary label gives you cheap planner pruning inside one database at the cost of application-level filtering.
- Never let a partition key be a boolean flag.
isActive: truefragments planner statistics and cannot back a selective index — the exact property graph anti-pattern partitioning is supposed to eliminate. Model state as a bounded, stable label instead. - Reserve distinct relationship types for cross-partition edges so they are discoverable and auditable, rather than allowing generic edges to silently bridge domains.
Step-by-step implementation
1. Establish partition-scoped labels and a node key
Partitioned labels should pair with a composite node key so ingestion and migration workers route deterministically. Neo4j 5.x makes this idempotent with IF NOT EXISTS:
// The node key doubles as the planner's backing index for tenant-scoped
// Customer lookups: one label pair, one composite identity key.
CREATE CONSTRAINT tenant_partition_unique IF NOT EXISTS
FOR (n:Customer:Active)
REQUIRE (n.tenantId, n.customerCode) IS NODE KEY;
2. Keep traversals inside the partition
Partition boundaries have to extend into relationship topology. Bound every traversal by relationship type and depth so a query anchored in one partition cannot silently expand into another:
// Anchored on an index-backed node, filtered by type and a single hop.
// No variable-length (*) expansion that could cross a partition edge.
MATCH (p:Product:PartitionA {sku: $sku})
MATCH (p)<-[:CONTAINS]-(c:Category:PartitionA)
WHERE c.region = $region
RETURN c.name, count(p) AS product_count;
Unbounded variable-length traversals (*) across partition edges frequently trigger full-graph scans and lock escalation. Enforce a depth limit (..3) and route through partition-scoped relationship types instead. These are exactly the relationship cardinality and directionality decisions that determine whether a query walks a predictable path or degenerates into a million-edge expansion.
3. Route through the Python driver with partition-safe transactions
The official Neo4j Python driver (5.x) emphasizes typed parameters, session routing, and retryable transaction functions. Because Cypher cannot parameterize label names, inject a partition label via string formatting only after validating it against an explicit allowlist — everything that is a value stays a typed parameter to preserve query-plan caching:
import asyncio
from typing import Any
from neo4j import AsyncGraphDatabase
ALLOWED_LABELS = {"Customer", "Order", "Product", "Device"}
async def fetch_partition_data(
driver: AsyncGraphDatabase,
tenant_id: str,
partition_label: str,
filters: dict[str, Any],
) -> list[dict[str, Any]]:
# Labels cannot be parameterized, so validate against an allow-list to
# avoid Cypher injection; all data values stay as typed parameters.
if partition_label not in ALLOWED_LABELS:
raise ValueError(f"Invalid partition label: {partition_label}")
query = """
MATCH (n:{label} {{tenantId: $tenant_id}})
WHERE n.status = $status AND n.region IN $regions
RETURN n.id AS id, n.name AS name, n.updatedAt AS updated_at
ORDER BY n.updatedAt DESC
LIMIT $limit
""".format(label=partition_label)
async with driver.session(
database="tenant_graph",
default_access_mode="READ",
) as session:
result = await session.run(
query,
tenant_id=tenant_id,
status="active",
regions=filters.get("regions", []),
limit=1000,
)
return [record.data() async for record in result]
Canonical driver patterns for partitioned architectures:
- Use
execute_read/execute_writetransaction functions so retries and partition-safe routing are handled for you; avoidsession.run()for writes. - Pass
database=,default_access_mode=, andtimeout=tosession()/execute_query()to pin database routing and bound execution time. - Wrap every mutation in
execute_writeso transient network partitions and lock contention retry gracefully rather than surfacing as application errors.
Constraint & validation layer
Isolation is only real if the database enforces it. Layer three controls so a boundary cannot be violated by a malformed write or a misrouted query.
Schema constraints. The node key from step 1 guarantees no two nodes in a partition collide on identity. Back tenant filters with a composite index so WHERE n.tenantId = $t stays index-seek rather than scan:
CREATE INDEX customer_tenant_region IF NOT EXISTS
FOR (n:Customer) ON (n.tenantId, n.region);
Access governance. For SaaS and multi-tenant platforms, partitioning becomes a security control plane. Map Neo4j role-based access control directly onto partition boundaries — restrict each role’s read/write scope to its tenant database or named partition. The full layered approach (label segregation + property routing + database RBAC) is detailed in best practices for multi-tenant graph schema isolation.
Ingestion-side provenance. Every node or edge that crosses a partition boundary should carry immutable provenance properties (_createdAt, _sourceSystem, _partitionVersion). Prefer native graph data types — datetime, duration, point — over serialized strings so lineage queries stay index-selective. Aligning these with a standard such as W3C PROV-DM keeps audit queries deterministic.
Performance & scale considerations
Partition design is ultimately a bet about cardinality and index selectivity. A secondary label like :Active only helps the planner if the labelled subset is a selective fraction of the store; if 95% of :Customer nodes are also :Active, the label prunes almost nothing and you have paid write amplification for no read benefit — model the minority state as the label instead.
- Batch sizing. Update partitioned relationships in discrete, batch-scoped transactions (typically 500–5,000 rows) so a migration never holds a store-wide lock. Larger batches raise throughput but widen the lock window and heap pressure; tune against observed lock-wait metrics, not a fixed constant.
- Index selectivity. A composite index on
(tenantId, region)collapses a tenant scan to a seek only whentenantIdis the leading, high-selectivity key. Order composite keys most-selective-first. - Cross-partition edges are expensive by design. Every relationship that bridges partitions is a traversal the planner cannot prune. Keep them rare, typed, and deliberately created — a super-node that fans out across tenants reintroduces exactly the contention partitioning removed.
Verify all of this empirically: run EXPLAIN to confirm the planner prunes by label and picks the composite index, then PROFILE a representative query to confirm zero AllNodesScan rows and bounded db-hits.
Schema evolution across partition boundaries
Partition boundaries must survive versioned migrations without breaking existing traversal contracts, which makes partitioning tightly coupled to schema evolution and versioning. Track partition state explicitly in dedicated metadata nodes rather than smuggling version logic into relationship properties:
MERGE (m:PartitionMetadata {partition: $partition})
SET m.version = "2.1", m.migratedAt = datetime();
When re-partitioning, adopt a dual-write plus backfill sequence: deploy the new constraints and indexes in parallel, route ingestion to both the legacy and updated partitions, backfill history with batched MERGE operations scoped by partition key, then deprecate the legacy labels only once PROFILE confirms zero fallback scans. For large backfills, an APOC-backed batch pipeline keeps each transaction bounded:
CALL apoc.periodic.iterate(
"MATCH (n:LegacyCustomer) WHERE n.tenantId = $tenant RETURN n",
"MERGE (c:Customer:Active {tenantId: n.tenantId, code: n.code})
SET c += properties(n), c._partitionVersion = '2.0'
WITH c, n
MATCH (n)-[:BOUGHT]->(p)
MERGE (c)-[:PURCHASED]->(p)",
{batchSize: 500, parallel: false, params: {tenant: "TENANT_A"}}
);
This idempotent MERGE pattern is the same one covered in depth under batch-processing and chunking workflows; reuse it so re-partitioning inherits the migration project’s retry and observability behaviour.
Known pitfalls
Partition key stored only as a property. A partitionId property forces every query into a WHERE-clause filter the planner applies after label resolution, so the partition never prunes the search space. Fix: promote the key to a secondary label and/or a node key so it drives index selection.
// Before: filtered post-hoc, no pruning.
MATCH (n:Customer) WHERE n.partitionId = 'A' RETURN n;
// After: the label is the partition, pruned before execution.
MATCH (n:Customer:PartitionA) RETURN n;
Unbounded traversal escaping the partition. A variable-length pattern with no depth cap ((a)-[*]->(b)) will follow any cross-partition edge it can reach, turning a scoped read into a full-graph walk under lock. Fix: cap depth and pin the relationship type — (a)-[:CONTAINS*..3]->(b).
RBAC and label boundaries drifting apart. When roles grant access at the database level but the application partitions by label inside a shared database, a bug in one query can read another tenant’s nodes. Fix: make the boundary singular — either isolate tenants into separate databases (RBAC-enforced) or gate every query through a single routing layer that injects the tenant filter; never split the responsibility.
Orphaned cross-boundary relationships after migration. Dual-write migrations that fail midway leave edges pointing between old and new partitions. Fix: set threshold monitors on _partitionVersion mismatches and run a reconciliation query that reports edges whose endpoints disagree on partition version before decommissioning any legacy label.
Observability
Bake monitoring into partition routing rather than bolting it on:
- Query plan stability —
EXPLAIN/PROFILEin CI to assert partition pruning and index usage per release. - Lock contention — track lock-wait time and transaction rollbacks on partitioned nodes via Neo4j server metrics.
- Driver telemetry — enable structured logging and OpenTelemetry spans to trace cross-partition latency.
- Partition drift — alert on
_partitionVersionmismatches and orphaned cross-boundary relationships.
When partition boundaries align with ingestion cadence, query routing, and schema versioning, Neo4j deployments achieve predictable scale, deterministic traversal cost, and enterprise-grade isolation. Treat partitioning as a first-class architectural contract, not an afterthought, and traversal bottlenecks, compliance leaks, and planner regressions all become design-time decisions instead of production incidents.
Related
- Up: Neo4j Graph Schema Design & Architecture — the parent reference tying together all six schema-design axes.
- Best practices for multi-tenant graph schema isolation — the RBAC-plus-label playbook this page routes to.
- Node label taxonomy design — the label vocabulary partition boundaries are built on.
- Relationship cardinality & directionality — enforcing bounded, typed edges across partitions.
- Schema evolution & versioning — migrating partition boundaries without breaking traversal contracts.