Constraint Lifecycle Management
A constraint in Neo4j 5.x is not a one-line declaration you write once and forget — it is a stateful schema object with a birth, an asynchronous provisioning window, an enforced steady state, and eventually a controlled retirement. Treat any of those phases carelessly and you get the failure modes platform teams see most often during a load: a MERGE that races into duplicates because its backing index was not yet ONLINE, a CREATE CONSTRAINT that aborts on data the source system swore was clean, or an orphaned index left behind after a constraint was dropped the wrong way. This reference defines the constraint types available in Neo4j 5.x, the index each one silently provisions, and the exact DDL ordering that makes constraint creation, replacement, and removal safe to run repeatedly against a live database.
Prerequisite Concepts
Constraints are one instrument in a larger tuning discipline, and they only pay off when the surrounding schema decisions are already sound. Before applying the lifecycle rules below, be comfortable with:
- The optimization context. Constraints exist to feed the cost-based planner a guaranteed-unique, index-backed lookup, so read them inside the parent Cypher Query Optimization & Index Lifecycle reference — plan analysis, index selection, and constraint governance are one continuous workflow.
- How the planner chooses an index. Every uniqueness and key constraint provisions a RANGE index, and whether that index is the right one depends on the property order and index-type trade-offs covered in Index Selection & Composite Indexing. A constraint is only as useful as the index it stands up.
- Idempotent write shape. A constraint makes
MERGErace-safe, but the write itself must anchor on the constrained key — the discipline detailed in error handling and rollback mechanisms for migration pipelines. - A bounded label vocabulary. Constraints attach to labels and relationship types, so a stable node label taxonomy must be fixed before you commit identity keys to it.
Conceptual Model
Two mental models govern everything that follows. The first is structural: a uniqueness or NODE KEY constraint is a logical rule that Neo4j enforces by owning a physical RANGE index. You declare the rule; the database provisions and manages the index underneath it. You never create that index yourself, and you must never create a duplicate standalone index on the same property — the planner would ignore it as dead weight.
The second model is temporal. A constraint moves through ordered phases, and the dangerous one is the gap between declared and online. On a non-empty database, CREATE CONSTRAINT returns as soon as the rule is accepted, then populates its backing index in the background. Any write issued in that window cannot use the index and may not yet be enforced.
Design Rules
Neo4j 5.x offers four constraint types, two of which provision an index. The table is the decision matrix: pick the type by the invariant you need to guarantee, and note what it costs at write time and what it provisions underneath.
| Constraint type | Cypher REQUIRE clause |
Backing index | Edition | Use when |
|---|---|---|---|---|
| Node property uniqueness | n.id IS UNIQUE |
RANGE (owned) | Community + Enterprise | A single property is the identity key for a label |
| Node key (composite) | (n.tenant, n.id) IS NODE KEY |
RANGE (owned, composite) | Enterprise | Identity is a tuple, and every part must exist and be jointly unique |
| Node property existence | n.id IS NOT NULL |
none | Enterprise | A property must always be present but need not be unique |
| Relationship property existence | r.since IS NOT NULL |
none | Enterprise | An edge must always carry a mandatory property |
Five rules fall out of this matrix and govern the whole lifecycle:
- Name every constraint explicitly. An auto-generated name is unstable across environments and makes
DROP CONSTRAINTscripts non-portable. Give each a deterministic name you can reference in migrations and inSHOW CONSTRAINTS. - Declare with
IF NOT EXISTS. It makes creation idempotent — replayable on every deploy without error — which is the baseline for treating DDL as version-controlled migration. - Never double-index a constrained property. The uniqueness or NODE KEY constraint already owns a RANGE index; a standalone
CREATE INDEXon the same key is redundant and the planner ignores it. Correct property ordering for the composite case is the subject of ordering properties in a composite index correctly. - A NODE KEY implies existence. Every property in a NODE KEY is automatically
IS NOT NULLand the tuple is unique — you do not layer a separate existence constraint on its members. - Constraint before first
MERGE, always. The backing index must beONLINEbefore the first write that relies on it, or that write scans and can race. Ordering is not a style preference; it is a correctness requirement.
Step-by-Step Implementation
Step 1 — Declare each constraint type
Bolt runs one statement per call, so schema DDL is issued as separate statements, never semicolon-batched. Each is idempotent and safe to replay.
// Node property uniqueness — the single-key identity constraint (all editions).
CREATE CONSTRAINT customer_id_unique IF NOT EXISTS
FOR (c:Customer) REQUIRE c.customer_id IS UNIQUE;
// Composite NODE KEY — tenant + id jointly unique, both mandatory (Enterprise).
CREATE CONSTRAINT account_tenant_key IF NOT EXISTS
FOR (a:Account) REQUIRE (a.tenant_id, a.account_id) IS NODE KEY;
// Node property existence — mandatory but not unique (Enterprise).
CREATE CONSTRAINT order_placed_at_exists IF NOT EXISTS
FOR (o:Order) REQUIRE o.placed_at IS NOT NULL;
// Relationship property existence — an edge must carry a property (Enterprise).
CREATE CONSTRAINT rated_since_exists IF NOT EXISTS
FOR ()-[r:RATED]-() REQUIRE r.since IS NOT NULL;
Step 2 — Inspect the current schema
SHOW CONSTRAINTS lists every rule and, crucially, the ownedIndex each uniqueness or key constraint manages. SHOW INDEXES reveals the physical state you gate on. Confirm what already exists before issuing new DDL so a migration never fights the current schema.
// Which constraints exist, and which index each one owns?
SHOW CONSTRAINTS YIELD name, type, entityType, labelsOrTypes, properties, ownedIndex;
// The physical index state — RANGE indexes owned by constraints appear here too.
SHOW INDEXES YIELD name, type, state, owningConstraint, populationPercent;
Step 3 — Gate dependent work on the ONLINE window
After declaring a constraint on a non-empty database, block until its backing index finishes populating. db.awaitIndexes waits for every index to reach ONLINE, or you can poll a single one by name. Never issue a dependent MERGE before this returns.
// Block the migration until all backing indexes are ONLINE (timeout in seconds).
CALL db.awaitIndexes(300);
// Or poll one constraint's index explicitly before running its dependent writes.
SHOW INDEXES YIELD name, state, owningConstraint
WHERE owningConstraint = 'customer_id_unique'
RETURN state; // proceed only when this returns 'ONLINE'
Step 4 — Provision the whole set from Python
The migration driver issues DDL idempotently, then awaits the online window before any load runs. One managed transaction per statement keeps each replayable, and the awaited gate closes the race the exemplar pipelines warn about.
from neo4j import GraphDatabase
# Ordered DDL: identity constraints first, then existence rules. Each statement
# is idempotent (IF NOT EXISTS) and issued on its own Bolt call.
CONSTRAINT_DDL = [
"CREATE CONSTRAINT customer_id_unique IF NOT EXISTS "
"FOR (c:Customer) REQUIRE c.customer_id IS UNIQUE",
"CREATE CONSTRAINT account_tenant_key IF NOT EXISTS "
"FOR (a:Account) REQUIRE (a.tenant_id, a.account_id) IS NODE KEY",
"CREATE CONSTRAINT order_placed_at_exists IF NOT EXISTS "
"FOR (o:Order) REQUIRE o.placed_at IS NOT NULL",
]
def provision_constraints(driver, *, await_seconds: int = 300) -> None:
"""Declare every constraint, then block until backing indexes are ONLINE."""
with driver.session(database="neo4j") as session:
for stmt in CONSTRAINT_DDL:
session.run(stmt).consume()
# The backing RANGE indexes populate asynchronously; do not load until
# they are ONLINE, or the first MERGE scans and can race into duplicates.
session.run("CALL db.awaitIndexes($t)", t=await_seconds).consume()
def main(uri: str, auth: tuple[str, str]) -> None:
with GraphDatabase.driver(uri, auth=auth) as driver:
driver.verify_connectivity()
provision_constraints(driver)
# ... only now is it safe to run the constraint-backed MERGE load ...
Step 5 — Replace or drop safely
To change a constraint’s definition — for example widening a single-key uniqueness rule into a composite NODE KEY — Neo4j 5.x cannot mutate it in place. You add the new constraint, wait for it to come online, then drop the old one. DROP CONSTRAINT removes the rule and its owned index together, so you never drop the index separately.
// Add the replacement first (its backing index provisions in the background).
CREATE CONSTRAINT account_tenant_key IF NOT EXISTS
FOR (a:Account) REQUIRE (a.tenant_id, a.account_id) IS NODE KEY;
// Only after the new one is ONLINE, retire the old single-key constraint.
// Dropping the constraint also drops the RANGE index it owned.
DROP CONSTRAINT account_id_unique IF EXISTS;
The zero-downtime sequencing for exactly that widening — dedup, backfill, add, await, drop — is the subject of migrating to NODE KEY constraints without downtime.
Constraint & Validation Layer
A constraint enforces its invariant on every commit, but only for data written after it is ONLINE. Creating a constraint on a table that already contains violating data fails outright with a ConstraintValidationFailed-class error, and the constraint is not created. That means the enforcement layer has two jobs: prove the constraint took effect, and prove the data it now guards is clean.
// Prove the identity constraint is live AND index-backed. A null ownedIndex,
// or an absent row, means MERGE would be scanning rather than seeking.
SHOW CONSTRAINTS YIELD name, type, ownedIndex
WHERE type IN ['UNIQUENESS', 'NODE_KEY'];
Because uniqueness and NODE KEY constraints refuse to create over dirty data, the enforcement discipline is to detect duplicates before the DDL, not to catch the exception after. A pre-flight aggregation surfaces exactly the offending keys so they can be quarantined or merged:
// Find composite-key collisions that would make the NODE KEY DDL fail.
MATCH (a:Account)
WITH a.tenant_id AS t, a.account_id AS id, count(*) AS n
WHERE n > 1
RETURN t, id, n ORDER BY n DESC;
The decision of whether a duplicate should be merged, quarantined, or rejected belongs to the broader contract-enforcement discipline in error handling and rollback mechanisms; the constraint is the last gate, not the first.
Performance & Scale Considerations
Constraint choices are performance choices, because the backing index and the validation cost both scale with the graph.
- Provisioning time scales with row count. On an empty database a constraint comes online instantly; on one holding a hundred million matching nodes, populating the backing RANGE index is a full scan-and-build that can take minutes. Declare identity constraints before the bulk load, not after, so the index builds incrementally as data lands rather than in one expensive backfill.
- Every uniqueness check is an index seek. A constrained
MERGEpays one index lookup per write to prove non-existence. That cost is bounded and predictable; the alternative — a post-load deduplication scan across the whole label — is unbounded and non-deterministic. Pay the write-time cost. - Composite NODE KEY selectivity depends on property order. The owned composite index is only exploited when the query’s equality predicates match the leading properties, so ordering the tuple by selectivity matters exactly as it does for any composite index. Ingesting overlapping keys under parallel writers also drives the contention analyzed in resolving duplicate nodes during parallel batch loads.
- Existence constraints are cheap but index-free.
IS NOT NULLvalidation is a per-write null check with no index to maintain, so it adds negligible cost — but it also provides no lookup acceleration. Do not expect an existence constraint to speed up a query; it only guarantees presence.
Known Pitfalls
-
Writing into the provisioning window. Symptom: duplicate identity keys appear despite a uniqueness constraint “existing”, and
PROFILEshows aNodeByLabelScanwhere aNodeUniqueIndexSeekwas expected. Root cause: the load started before the backing index reachedONLINE, soMERGEscanned and two concurrent writers each inserted. Fix: gate every dependent write ondb.awaitIndexesor aSHOW INDEXES ... state = 'ONLINE'poll, as in Step 3. -
Creating a constraint over dirty data. Symptom:
CREATE CONSTRAINTaborts with a constraint-validation error and no constraint is created. Root cause: the label already holds rows that violate the rule — duplicate keys or null values. Fix: run the pre-flight aggregation from the validation layer, remediate or quarantine the offenders, then retry the DDL. -
Dropping the owned index instead of the constraint. Symptom:
DROP INDEXfails with an error that the index is owned by a constraint, or the schema ends up with a rule that has lost its index. Root cause: attempting to remove the backing index directly. Fix: alwaysDROP CONSTRAINT— it removes the rule and its owned index together; you never manage that index independently. -
Redundant standalone index on a constrained key. Symptom:
SHOW INDEXESlists two indexes on the same label/property, and one never shows planner usage. Root cause: someone addedCREATE INDEXon a property that a uniqueness constraint already backs. Fix: drop the standalone index; the constraint’s owned RANGE index already serves every seek.
Related
- Migrating to NODE KEY Constraints Without Downtime — the live-database sequence for widening a single key into a composite NODE KEY.
- MERGE vs CREATE for Idempotent Upserts — why the constraint above is what makes
MERGEboth race-safe and fast. - Up: Cypher Query Optimization & Index Lifecycle — the parent reference this constraint discipline sits inside.
- Index Selection & Composite Indexing — choosing and ordering the index a constraint provisions.
- Error Handling & Rollback Mechanisms — routing the constraint violations this layer surfaces during a load.