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 :NeverWrittenThis 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
:Personnodes with different property sets ({name, born}vs{username, dob}). - A property named
emailon one node ande_mailon another. - A
:FOLLOWSedge with anactiveproperty on one and not on another. - A property value that's an
Integerin one place andStringin 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 existsAnd break in this one:
// Empty graph
MATCH (s:NeverWritten) RETURN s; // analysis error on a populated graphMERGE for idempotent writes
MERGE is the write-side idempotency tool: it matches on the given
pattern, creating only if missing. Add a uniqueness constraint when you
also need the database to reject duplicate keys:
MERGE (u:User {email: $email})
ON CREATE SET u.created = temporal.timestamp()
ON MATCH SET u.last_seen = temporal.timestamp()It's an important building block for schema-free writes:
- Safe upsert — a repeated run won't create duplicates.
- Constraint-friendly — a matching uniqueness constraint rejects competing duplicate writes. See Constraints.
- No index required — without a supporting index or constraint,
MERGEscans the label/type scope for 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 type.of(r.id) = 'INTEGER'
RETURN rSee Functions → type conversion and checking
for type.of, toInteger, toString, and friends.
Trade-offs at a glance
| Property | Traditional schema | LoraDB |
|---|---|---|
| Declare up front | Required (CREATE TABLE) | Not required |
| Add a new property | Migration | Just SET it |
| Enforce "every node has X" | Constraint | Application code |
| Enforce "X is unique" | UNIQUE | MERGE on the key |
| Catch typos in writes | Schema | Code review / tests |
| Catch typos in reads | Schema | Analyzer rejects unknown labels/types |
| Index lookups | Explicit schema-managed indexes | Optional RANGE/TEXT/POINT/LOOKUP/VECTOR/FULLTEXT indexes for performance and search |
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_userfunction that's the only writer of:Usernodes and always sets the same property keys. - A
MERGEon 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
- Graph data model — nodes, relationships, properties.
- MERGE — idempotent writes.
- Properties — missing vs null, value typing.
- Troubleshooting → Semantic errors — typo-catching on reads.
- Limitations → Storage — scoped index and constraint coverage.