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 MERGE race-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.

A constraint owns a backing RANGE index the planner exploits Left box: a UNIQUENESS or NODE KEY constraint. An arrow labelled "auto-provisions and owns" points right to a RANGE index box. A second arrow labelled "seeks, never scans" points from the RANGE index to a Cypher planner box. Below, a note reads: property-existence constraints enforce a rule but provision no index. auto-provisions seeks, never scans and owns UNIQUENESS / NODE KEY backing RANGE index Cypher planner Property-existence constraints (IS NOT NULL) enforce a rule but provision NO index — Enterprise only
Uniqueness and key constraints own an index the planner uses; existence constraints only validate.

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.

The five-phase constraint lifecycle Five ordered phases on a timeline: Declare issues CREATE CONSTRAINT IF NOT EXISTS; Provisioning populates the backing index asynchronously and is the only unsafe window; Online means the backing index reports state ONLINE; Enforced means every write is validated against the rule; Drop removes the constraint and its owned index together. An arrow spans the timeline left to right. GATE DEPENDENT WRITES ON THE ONLINE PHASE, NOT ON DECLARE 1 Declare CREATE ... IF NOT EXISTS 2 Provisioning index POPULATING unsafe window 3 Online backing index state ONLINE 4 Enforced writes validated on every commit 5 Drop rule + owned index removed
Only phase 2 is dangerous; every dependent write must wait for phase 3.

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:

  1. Name every constraint explicitly. An auto-generated name is unstable across environments and makes DROP CONSTRAINT scripts non-portable. Give each a deterministic name you can reference in migrations and in SHOW CONSTRAINTS.
  2. 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.
  3. Never double-index a constrained property. The uniqueness or NODE KEY constraint already owns a RANGE index; a standalone CREATE INDEX on 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.
  4. A NODE KEY implies existence. Every property in a NODE KEY is automatically IS NOT NULL and the tuple is unique — you do not layer a separate existence constraint on its members.
  5. Constraint before first MERGE, always. The backing index must be ONLINE before 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.

cypher
// 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;
cypher
// 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;
cypher
// 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;
cypher
// 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.

cypher
// Which constraints exist, and which index each one owns?
SHOW CONSTRAINTS YIELD name, type, entityType, labelsOrTypes, properties, ownedIndex;
cypher
// 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.

cypher
// Block the migration until all backing indexes are ONLINE (timeout in seconds).
CALL db.awaitIndexes(300);
cypher
// 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.

python
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.

cypher
// 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;
cypher
// 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.

cypher
// 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:

cypher
// 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 MERGE pays 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 NULL validation 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

  1. Writing into the provisioning window. Symptom: duplicate identity keys appear despite a uniqueness constraint “existing”, and PROFILE shows a NodeByLabelScan where a NodeUniqueIndexSeek was expected. Root cause: the load started before the backing index reached ONLINE, so MERGE scanned and two concurrent writers each inserted. Fix: gate every dependent write on db.awaitIndexes or a SHOW INDEXES ... state = 'ONLINE' poll, as in Step 3.

  2. Creating a constraint over dirty data. Symptom: CREATE CONSTRAINT aborts 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.

  3. Dropping the owned index instead of the constraint. Symptom: DROP INDEX fails 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: always DROP CONSTRAINT — it removes the rule and its owned index together; you never manage that index independently.

  4. Redundant standalone index on a constrained key. Symptom: SHOW INDEXES lists two indexes on the same label/property, and one never shows planner usage. Root cause: someone added CREATE INDEX on a property that a uniqueness constraint already backs. Fix: drop the standalone index; the constraint’s owned RANGE index already serves every seek.