Skip to main content

Schema-Free Writes and Soft Validation

Writes are permissive, reads are strict. CREATE / MERGE / SET accept any label, relationship type, or property key without a CREATE TABLE step — names come into existence the first time they're written. MATCH refuses labels and relationship types the live graph has never seen. Property keys on MATCH stay lenient (missing key → null).

That split is deliberate: permissive writes keep iteration fast, and strict reads catch typos before they silently return zero rows.

What "schema-free" actually means

The graph tracks three things as the process runs:

  • The set of labels seen on any node since process start.
  • The set of relationship types seen on any relationship.
  • The property keys seen on any node or relationship.

No declaration, no ALTER TABLE, no migration. The first write that mentions a new name brings it into existence; subsequent writes reuse it.

CREATE (c:Country {name: 'NL', iso: 'NLD'})

On an empty graph, this creates the label Country and the property keys name and iso. The next MATCH (:Country) will succeed.

The opposite is not true

A MATCH for a label that was never created fails at analysis:

MATCH (u:NeverWritten) RETURN u
-- Unknown label :NeverWritten

This is deliberate — typo-catching. The alternative (silently return zero rows) hides the bug until your integration tests reach production. See Troubleshooting → Semantic errors.

Permissive writes

CREATE, MERGE, and SET accept any name without complaint.

CREATE (:Spaceship {name: 'Rocinante', crew: 4})
-- "Spaceship" was never declared. Fine — it now exists.

MATCH (s:Spaceship)
SET s.engine = 'Epstein drive'
-- Adds a new property key; totally legal.

This is good for quick iteration and bad for safety. There is no constraint preventing you from creating a second :Spaceship with completely different properties, or from typo-ing Spaceshi and polluting the label set.

Things the engine won't catch

  • Two :Person nodes with different property sets ({name, born} vs {username, dob}).
  • A property named email on one node and e_mail on another.
  • A :FOLLOWS edge with an active property on one and not on another.
  • A property value that's an Integer in one place and String in another.

If any of these matter, enforce them at the application layer, or in a MATCH-before-CREATE idiom, or with MERGE.

Strict reads

MATCH validates label and relationship-type names against live graph state. The "live" part matters:

  • On an empty graph, every label and type is unknown — but MATCH (:Foo) on an empty graph succeeds with zero rows. There's nothing to validate against.
  • On a populated graph, the label has to have been seen before.

Property keys in MATCH are not validated this way — a missing property simply yields null on access. See Properties → missing vs null.

Reading back what you wrote

The two rules meet cleanly in this pattern:

CREATE (:Spaceship {name: 'Rocinante'});
MATCH (s:Spaceship) RETURN s; -- works — :Spaceship now exists

And break in this one:

-- Empty graph
MATCH (s:NeverWritten) RETURN s; -- analysis error on a populated graph

MERGE for idempotent writes

MERGE is the closest thing LoraDB has to a uniqueness constraint — it matches on the given pattern, creating only if missing:

MERGE (u:User {email: $email})
ON CREATE SET u.created = timestamp()
ON MATCH SET u.last_seen = timestamp()

It's an important building block for schema-free writes:

  • Safe upsert — a repeated run won't create duplicates.
  • No indexes requiredMERGE does a full-label scan on the key map, which is fine for moderate scales. See Limitations → Storage.

See MERGE for the full reference.

Runtime type checks

Because a property's type is only enforced when written — not when declared — you occasionally need to verify it at query time:

MATCH (r:Record)
WHERE valueType(r.id) = 'INTEGER'
RETURN r

See Functions → type conversion and checking for valueType, toInteger, toString, and friends.

Trade-offs at a glance

PropertyTraditional schemaLoraDB
Declare up frontRequired (CREATE TABLE)Not required
Add a new propertyMigrationJust SET it
Enforce "every node has X"ConstraintApplication code
Enforce "X is unique"UNIQUEMERGE on the key
Catch typos in writesSchemaCode review / tests
Catch typos in readsSchemaAnalyzer rejects unknown labels/types
Index lookupsFastNo property indexes — scope to a label

When to add a "soft schema" at the app layer

Schema-free is a tool, not a lifestyle. If your data model stabilises, pin it down in host code:

  • A small module that returns the valid labels / types and fails fast on typos.
  • A create_user function that's the only writer of :User nodes and always sets the same property keys.
  • A MERGE on the business key rather than letting callers fan out to different shapes.

You lose the "schema" catch-your-typo net. Good architecture puts the net back, where it's cheap.

See also