Best practices for multi-tenant graph schema isolation
You are running many tenants inside one Neo4j database and need a boundary the Cypher planner can enforce on every read and write — one that makes cross-tenant leakage structurally impossible, keeps each tenant’s query plan on an index seek rather than a full label scan, and satisfies GDPR, SOC 2, and HIPAA audit requirements — all without paying the operational and licensing cost of a separate database per tenant. This page walks through the exact schema, traversal, and governance controls that deliver that guarantee, with the diagnostic steps to prove isolation holds.
Prerequisites
Choose logical isolation, then make it mandatory
The first fork is physical versus logical isolation. A dedicated database per tenant gives hard boundaries but multiplies backup, upgrade, and page-cache overhead and does not scale past a few dozen tenants on a single instance. Logical isolation inside one graph is the pattern that scales — but only when the boundary is enforced by a constraint the planner trusts, not by application discipline alone.
The reproducible production pattern is a mandatory tenantId on every tenant-scoped node, backed by a composite node key. The key forces the planner to resolve a tenant-scoped index lookup on the first execution step, and the existence constraint makes an un-tenanted node impossible to write:
// Every tenant-scoped label carries a NOT NULL tenantId...
CREATE CONSTRAINT customer_tenant_presence IF NOT EXISTS
FOR (c:Customer) REQUIRE c.tenantId IS NOT NULL;
// ...and a composite NODE KEY so (tenantId, businessKey) is unique AND indexed.
// The planner uses this index to prune to one tenant before traversal begins.
CREATE CONSTRAINT customer_tenant_key IF NOT EXISTS
FOR (c:Customer) REQUIRE (c.tenantId, c.customerId) IS NODE KEY;
// Repeat both constraints for each tenant-scoped label (Order, Device, Invoice…).
CREATE CONSTRAINT order_tenant_key IF NOT EXISTS
FOR (o:Order) REQUIRE (o.tenantId, o.orderId) IS NODE KEY;
IF NOT EXISTS keeps this DDL idempotent so it can run on every deploy — the same discipline covered in schema evolution and versioning. Pick the property type deliberately: use STRING for UUID or slug tenant identifiers because they hash and index predictably, or INTEGER for numeric IDs to shrink the store and speed equality checks. Never route tenancy through an array or map property — that bypasses the native index and forces a runtime scan, a trap detailed in graph data type selection.
The layout below contrasts a guarded tenant boundary against a leaking cross-tenant edge — the exact failure the constraints and traversal rules below are designed to prevent.
Constrain traversals so edges cannot cross the boundary
Constraints protect nodes; they do not stop a relationship walk from wandering into another tenant. An unguarded MATCH (n)-[r]-(m) — especially a variable-length *1..5 traversal — will happily follow any edge that exists, so the boundary has to be re-asserted on the traversal itself. This is where relationship cardinality and directionality rules matter: anchor every traversal at the tenant root and re-bind tenantId on each hop.
// Anchor at the tenant, then re-assert tenantId on every downstream node.
// The planner seeds from the Customer node key, so it never leaves the tenant.
MATCH (c:Customer {tenantId: $tenantId})-[:PLACED]->(o:Order)
WHERE o.tenantId = $tenantId // guard the far side of every hop
MATCH (o)-[:CONTAINS]->(li:LineItem)
WHERE li.tenantId = $tenantId
RETURN o.orderId, collect(li.sku) AS items;
Always pass $tenantId as a bound parameter rather than interpolating it into the query string. Parameterization lets Neo4j cache one plan across all tenants; string interpolation produces a distinct plan per tenant and bloats the plan cache until it thrashes. Keeping a single canonical label set (Customer, Order, Device) rather than generating Customer_TenantA labels is what makes that shared plan possible — per-tenant labels fragment the page cache and defeat plan reuse.
Enforce the boundary at the application layer (Python)
For defence in depth, inject and verify tenantId in the driver so an accidental omission fails loudly instead of scanning the whole graph. The neo4j v5 driver’s context-managed sessions make this clean:
from neo4j import GraphDatabase
import logging
def execute_tenant_query(driver, tenant_id, query, params=None):
# tenantId is always injected here — callers cannot forget it.
merged = {"tenantId": tenant_id, **(params or {})}
with driver.session() as session:
# Staging-only guard: PROFILE the query and flag any label scan,
# which means a tenant predicate is missing. Never run PROFILE
# on production hot paths — it adds measurable overhead.
summary = session.run(f"PROFILE {query}", **merged).consume()
if "NodeByLabelScan" in str(summary.profile):
logging.warning("Tenant isolation bypass for tenant %s", tenant_id)
# Real read, without PROFILE, via a managed read transaction.
return session.execute_read(
lambda tx: tx.run(query, **merged).data()
)
# One driver per process; reuse it — it owns the connection pool.
driver = GraphDatabase.driver(uri, auth=(user, password))
Layer database RBAC underneath this. Map each database role to a tenant scope and grant least privilege, so even a compromised credential cannot read outside its labels:
// Restrict a role to the labels a tenant is allowed to read.
GRANT TRAVERSE ON GRAPH neo4j NODES Customer, Order, LineItem TO tenant_reader;
GRANT READ {*} ON GRAPH neo4j NODES Customer, Order, LineItem TO tenant_reader;
Validation and verification
Prove the isolation holds before you trust it:
PROFILEa representative tenant query. The first operator must beNodeIndexSeek(orNodeUniqueIndexSeek), neverNodeByLabelScan— a label scan means the composite key is not being used and the query touches every tenant.SHOW CONSTRAINTS/SHOW INDEXESand confirm each tenant key reportsONLINE. APOPULATINGorFAILEDindex silently forces scans.- Count-check the boundary. This query must return zero rows; any row is a cross-tenant edge that escaped the guards:
MATCH (a)-[r]->(b)
WHERE a.tenantId IS NOT NULL
AND b.tenantId IS NOT NULL
AND a.tenantId <> b.tenantId
RETURN a.tenantId AS fromTenant, b.tenantId AS toTenant, count(r) AS leaks
ORDER BY leaks DESC;
Wire steps 1 and 3 into CI against a staging dataset so an index-bypass regression fails the build rather than shipping.
Edge cases and gotchas
Un-tenanted reference nodes break the count-check. Shared reference data (currency codes, country nodes) legitimately has no tenantId. The zero-leak query above already excludes them via b.tenantId IS NOT NULL, but any traversal that reaches shared nodes and back can still bridge tenants. Keep shared data on read-only labels and never let a PLACED-style ownership edge terminate on them.
Tenant offboarding must not orphan referential integrity. A hard DETACH DELETE of a whole tenant mid-traffic can deadlock against in-flight reads. Route deletions through a soft-delete flag and a scheduled purge instead:
// Soft-delete now; a scheduled job purges after the retention window.
MATCH (n {tenantId: $tenantId})
SET n.isDeleted = true, n.deletedAt = datetime();
A missing predicate on one hop leaks the whole subgraph. Guarding the anchor but forgetting WHERE o.tenantId = $tenantId on a later hop lets a variable-length walk cross into another tenant through a shared intermediate node. Re-assert tenantId on every matched node, and prefer bounded traversals (*1..3) over unbounded ones so an escaped predicate cannot fan out across the graph. This is one of the property graph anti-patterns that partitioning exists to prevent.
Parent context
This pattern is one boundary type within graph partitioning strategies — read that page for the wider decision of where partition boundaries should live and how they map onto RBAC and versioned migrations.
Related
- Graph Partitioning Strategies — the parent guide to drawing logical and physical partition boundaries.
- Node Label Taxonomy Design — why a canonical label set beats per-tenant labels for plan reuse.
- Relationship Cardinality & Directionality — modeling edges so traversals cannot cross the tenant boundary.
- Property Graph Anti-Patterns — the cross-tenant failure modes this isolation pattern guards against.