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
: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 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 required —
MERGEdoes 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
| 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 | Fast | No 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_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 — no indexes, no constraints.