+1 (415) 649-9454

Constraints That Earn Their Keep: Node Keys, Property Existence, and Type Constraints in Neo4j

Neo4j is schema-optional, and most teams read that as schema-free. It is not. A production graph without declared constraints is a graph where any service, any ad-hoc Cypher session, and any half-finished import script can write status: 1 where every other node has status: "ACTIVE", or create a second Customer with the same customerId. You find out months later, usually in an aggregation that silently drops rows.

Neo4j's constraint catalogue has grown considerably — uniqueness, node and relationship property existence, node keys, relationship keys, and property type constraints — and Cypher 25 makes the whole set inspectable and migratable. This tutorial walks through designing a constraint set for a real graph, rolling it onto data that already violates it, and keeping it under version control so it is part of your deploy rather than a thing someone did once in Browser.

The five constraint types, and what each one actually buys you

ConstraintGuaranteesIndex created?
Uniqueness (node / relationship property)No two nodes of that label share the property valueYes — range index
Property existenceThe property is present and non-nullNo
Property typeThe property's value is one of an allowed type setNo
Node keyExistence + uniqueness over a property combinationYes
Relationship keySame, for relationship propertiesYes

Uniqueness and key constraints are available in Community; existence and type constraints are Enterprise features. That split matters when you plan: if your CI runs Community and production runs Enterprise, your test database will happily accept data production rejects. Run Enterprise in Testcontainers with the dev licence acceptance flag so the two agree.

Step 1 — audit what exists

Start from the database, not from the model you think you have.

SHOW CONSTRAINTS YIELD name, type, entityType, labelsOrTypes, properties, propertyType
ORDER BY entityType, labelsOrTypes, name;

Then compare that with what the data really looks like. db.schema.nodeTypeProperties() reports, per label, every property it found and every type it was found in:

CALL db.schema.nodeTypeProperties()
YIELD nodeLabels, propertyName, propertyTypes, mandatory
WHERE size(propertyTypes) > 1
RETURN nodeLabels, propertyName, propertyTypes, mandatory
ORDER BY nodeLabels, propertyName;

Every row that comes back is a property stored as more than one type — a String here, a Long there. On a mature graph this query is uncomfortable reading. It is also your worklist. (On very large stores it samples; treat it as a strong hint, then confirm per property with a counting query.)

MATCH (o:Order)
WHERE o.total IS NOT NULL AND NOT o.total IS :: FLOAT
RETURN count(*) AS offenders, collect(DISTINCT valueType(o.total))[0..5] AS types;

Two things to note: the IS :: type predicate and valueType() are the Cypher-native way to ask about types, and they beat the old apoc.meta.* round trip — part of the broader APOC-to-core migration worth doing anyway.

Step 2 — declare the constraints

Identity first. Node keys are the strongest statement you can make about a business identifier:

CREATE CONSTRAINT customer_key IF NOT EXISTS
FOR (c:Customer) REQUIRE (c.customerId) IS NODE KEY;

CREATE CONSTRAINT order_key IF NOT EXISTS
FOR (o:Order) REQUIRE (o.orderId) IS NODE KEY;

A node key gives existence and uniqueness and a backing index, so it replaces the CREATE CONSTRAINT ... IS UNIQUE plus IS NOT NULL pair you may have written separately. Composite keys work the same way: REQUIRE (a.tenantId, a.accountRef) IS NODE KEY — the natural companion to a multi-tenant design.

Then types. This is the newer capability and the one most teams have not adopted:

CREATE CONSTRAINT order_total_type IF NOT EXISTS
FOR (o:Order) REQUIRE o.total IS :: FLOAT;

CREATE CONSTRAINT order_placed_type IF NOT EXISTS
FOR (o:Order) REQUIRE o.placedAt IS :: ZONED DATETIME;

CREATE CONSTRAINT customer_tags_type IF NOT EXISTS
FOR (c:Customer) REQUIRE c.tags IS :: LIST<STRING NOT NULL>;

ZONED DATETIME is the one to insist on. A graph that mixes LOCALDATETIME, epoch milliseconds as INTEGER, and ISO strings is a graph where time-window queries are guesswork — and temporal correctness is the whole foundation of patterns like bi-temporal agent memory.

Relationships deserve the same treatment. Relationship property existence and type constraints exist, and a relationship key covers the identity case:

CREATE CONSTRAINT rated_score_type IF NOT EXISTS
FOR ()-[r:RATED]-() REQUIRE r.score IS :: INTEGER;

CREATE CONSTRAINT rated_at_exists IF NOT EXISTS
FOR ()-[r:RATED]-() REQUIRE r.ratedAt IS NOT NULL;

A type constraint can also accept a union — IS :: INTEGER | FLOAT — which is a useful escape hatch during a migration, but treat any union that survives past the migration as unfinished work.

Step 3 — fix the data before the constraint, not after

CREATE CONSTRAINT fails if existing data violates it, and the error names only the first offender. On a graph of any size, discovering violations one aborted statement at a time is miserable. Do it in the right order: find offenders, repair in batches, then declare.

MATCH (o:Order)
WHERE o.total IS NOT NULL AND NOT o.total IS :: FLOAT
CALL (o) {
  SET o.total = toFloat(o.total)
} IN CONCURRENT TRANSACTIONS OF 10000 ROWS;

That is the Cypher 25 form of batched writes; if you are on an older runtime, CALL { ... } IN TRANSACTIONS is the equivalent, and the bulk-write patterns post covers lock contention when the batches get large.

Duplicates are the harder class. Find them before you declare a key:

MATCH (c:Customer)
WITH c.customerId AS id, collect(c) AS nodes
WHERE id IS NOT NULL AND size(nodes) > 1
RETURN id, size(nodes) AS copies
ORDER BY copies DESC LIMIT 50;

Merging duplicates is a data decision, not a schema one — which node wins, and what happens to the relationships on the losers. If the duplicates came from imperfect source data rather than a buggy loader, this is an entity resolution problem and deserves that treatment rather than a quick apoc.refactor.mergeNodes across the board.

Nodes missing a required property are the third class. Decide explicitly: backfill a default, or delete them as junk. Never let an import script quietly invent values.

Step 4 — put the schema in migrations

Hand-typed constraints drift. Constraint DDL belongs in the same versioned migration set as everything else, with IF NOT EXISTS so re-running is a no-op:

// V014__order_constraints.cypher
CREATE CONSTRAINT order_key IF NOT EXISTS
FOR (o:Order) REQUIRE (o.orderId) IS NODE KEY;
CREATE CONSTRAINT order_total_type IF NOT EXISTS
FOR (o:Order) REQUIRE o.total IS :: FLOAT;

Name every constraint. Unnamed constraints get generated names that differ between environments, which makes diffing schemas across dev, staging, and production pointless. A simple <entity>_<property>_<kind> convention is enough.

Then assert the schema in CI. Spin up a container, run the migrations, and fail the build when the live catalogue does not match the committed expectation:

SHOW CONSTRAINTS YIELD name, type, labelsOrTypes, properties, propertyType
RETURN name, type, labelsOrTypes, properties, propertyType
ORDER BY name;

Snapshot that result and compare. A constraint deleted by hand in production now shows up as a failing test instead of as corrupt data six weeks later.

Step 5 — know the performance bill

Constraints are not free, and the cost is not where people expect.

  • Key and uniqueness constraints create indexes. You pay write cost and memory for them, but you also get a lookup index you probably wanted. Account for it in your page cache sizing.
  • Existence and type constraints create no index. Their cost is a per-write validation, which is small and constant. They do not help reads at all — do not expect the planner to use them.
  • Creating a constraint on a large label is an online but expensive operation. Watch SHOW INDEXES for population state and run it in a quiet window; on a cluster the work happens on every member.
  • MERGE on a node-key property gets faster, because the backing index turns the match half of the MERGE into a seek instead of a label scan.

One planner caveat worth knowing: a uniqueness constraint tells the planner a property-equality match returns at most one row, which can change a join order for the better. Existence and type constraints currently offer the planner nothing, so if a query got slower after a schema change, go read the plan — our PROFILE walkthrough explains how.

A constraint set that is enough for most graphs

For each core entity label:

  1. One node key on the business identifier.
  2. Existence constraints on properties your application dereferences without a null check.
  3. Type constraints on every temporal and numeric property, and on list properties (LIST<STRING NOT NULL> catches the nulls-in-a-list bug).
  4. For each relationship type carrying properties: existence and type constraints on those properties.
  5. Nothing else. Constraints you cannot justify are write overhead and migration friction.

Resist the urge to model everything. The point is not to turn Neo4j into a relational database; it is to make the handful of guarantees your queries silently rely on explicit, enforced, and reviewable.

Where this usually goes wrong

The failure mode we see most often in health checks is not an absent constraint set — it is a constraint set that exists in dev and was never applied in production, because the production database predates the migration tooling and nobody wanted to be the person who ran a key constraint against 400 million nodes. That migration is very doable: audit, batch-repair, declare in a maintenance window, verify. It is a week of careful work, not a rewrite.

If your graph has drifted far enough that you are not sure what a clean schema would even look like, that is the normal starting point. GraphGuru's senior Neo4j consultants do exactly this work — schema audits, constraint rollouts on live production graphs, and the migration tooling to keep them from drifting again. Get in touch with a description of your graph and we will tell you what the audit would find.