How strict should your schema be?
Schema strictness in databases is often thought of as an on/off switch. In this post, we explore the full spectrum of type safety in TypeDB, from minimal to ultra-strict.

Database marketing has trained us to think of schema as a switch. On one side, “schema-optional” graph and document stores promise you can start writing data immediately and worry about structure later. On the other, “schema-first” systems like SQL databases demand you declare tables up front and reward you with integrity guarantees. Pick your religion.
But strictness was never binary. Consider the following independent questions:
- Are your types an enumerated, closed set, or ad-hoc strings?
- Is it defined who can participate in which relationships?
- Is it defined how many? Attributes per owner, players per relation?
- Are values constrained: enums, ranges, patterns?
- Is identity enforced: keys, uniqueness?
- And when data violates any of this, is it an error, or simply ignored?
None of these questions has to have a fixed answer, in any database. SQL never forces a PRIMARY KEY or a CHECK on you. But the range of available options varies enormously. Neo4j Community Edition’s only data constraint is property uniqueness.
In this post, we’ll explore schema strictness from one end to the other, starting with a TypeDB schema so loose it’s essentially a labeled property graph, through the industry-standard strictness of SQL and on to a richly typed domain model, ending with guarantees that, as far as we know, no other production database can express.
Approximating a labeled property graph in TypeDB
Here is a complete, valid TypeDB schema:
define
attribute obj_label, value string;
attribute prop_key, value string;
attribute prop_value, value string;
entity node,
owns obj_label @card(0..),
plays property:owner,
plays edge:source,
plays edge:target;
relation edge,
owns obj_label @card(1),
plays property:owner,
relates source,
relates target;
relation property,
relates owner,
owns prop_key @card(1),
owns prop_value @card(1);
This is a simplified labeled property graph: nodes with any number of string labels, connected by edges with exactly one label, where both nodes and edges can have any arbitrary properties. (In this micro-schema, all property values are encoded as strings.)
insert
$alice isa node, has obj_label "person";
$acme isa node, has obj_label "company";
$_ isa property, links (owner: $alice), has prop_key "name", has prop_value "alice";
$_ isa property, links (owner: $acme), has prop_key "name", has prop_value "acme";
$e isa edge, has obj_label "employment",
links (source: $alice, target: $acme);
If you’ve worked with Neo4j, this should be familiar territory. Any domain fits into it. This looseness is well-suited to exploratory analysis, fast-changing ingestion pipelines, and datasets whose structure you’re still discovering. The price is that mistyping a label returns nothing, thus it fails silently rather than showing a proper error:
match
$n isa node, has obj_label "perzon"; # typo: no error, just no results
In summary: you can insert whatever data you like without migrating schema. But that flexibility comes at a cost. Your domain has structure, but your database is unaware of it. Back in 2013, Martin Fowler authored a presentation on “schemaless” data stores, noting that schemaless “reduces ceremony and increases flexibility”. He then goes on to observe that schemaless structures still have an implicit schema:

“Any code that manipulates the data needs to make some assumptions about its structure, such as the name of fields. Any data that doesn’t fit this implicit schema will not be manipulated properly, leading to errors.”
“Schemaless Data Structures”, Martin Fowler, 2013
The counterpart of schemaless data stores is structured data – the long-held industry standard, so let’s look into that next.
The industry standard
The level of strictness the database industry has actually standardized on is seen commonly in SQL systems such as Postgres, MySQL and Microsoft SQL: enumerated types, required fields, keys, enumerated values, typed references.
Here’s a simple SQL-like schema in TypeDB, distilled from the RBAC in Business guide:
define
attribute name, value string;
attribute email, value string;
attribute start_date, value date;
attribute status, value string @values("active", "suspended", "terminated");
entity staff,
owns name @card(1),
owns email @key,
owns status @card(1),
plays reports_to:manager,
plays reports_to:report;
relation reports_to,
relates manager @card(1),
relates report @card(1),
owns start_date @card(1);
Every declaration has a direct SQL counterpart. staff and reports_to are tables; @card(1) is NOT NULL; @key is PRIMARY KEY; @values is a CHECK; the two typed roles are a pair of non-null foreign keys. If you’re coming from an SQL background, nothing here will surprise you.
The typo that caused the query to fail silently at the loose end now fails loudly. Just like in SQL, this is desirable behaviour – the query is incorrect, and now it’s able to tell you so.
match
$s isa staf; # error: 'staf' is not a defined type
Another example:
match
$s isa staff, has start_date $d; # error: staff doesn't own start_date
This query looks convincing enough at face value. Intuitively, a staff member has a date that they start work. However, we haven’t defined that in the schema, so TypeDB rejects it at read time, distinguishing an invalid query returning no results (an error) from a correct query returning no results (an empty answer). SQL catches this class of mistake too: a missing column is a compile error. Cypher does not: MATCH (s:Staff) RETURN s.start_date returns a column of nulls.
The graph world is gradually moving in this direction. As of Neo4j 2026.06, Enterprise Edition with Cypher 25 supports graph types, which can type relationship endpoints (“LIVES_IN runs from Resident to City”). We previously noted this in a blog: Everyone is making structured graphs now.
A richly typed domain model
Real domains are rarely flat. A payment is a card payment or a bank transfer, and only one of them has a sort code. Staff can be permanent or temporary, where only temporary staff have a contract with an end date. Nor are they uniform: a customer may hold three phone numbers and no fax, and a ticket may be escalated to a manager but never to a contractor.
These kinds of requirements are common, yet they don’t always make it into a database’s knowledge base. SQL has no usable inheritance, so subtypes become nullable columns or extra tables. A foreign key points at exactly one table, so a reference that could resolve to several types has to give up integrity. You absolutely could use the same, weak, model in TypeDB. Write an automated migrator from SQL to TypeDB, and you’d likely end up with the industry standard: a schema like SQL, where much nuance is unknown to the DB and enforced only at the application layer.
One of the key goals of TypeDB is to bridge that gap. The following partial schema for staff in an organisation leverages only the basic functionalities of TypeDB, yet is sufficient to constitute a more domain-aware model: one that brings a better understanding of the domain to the database layer.
define
attribute phone_number, value string;
attribute contract_end_date, value date;
entity staff @abstract,
owns name @card(1),
owns email @key,
owns status @card(1),
owns phone_number @card(0..3),
plays reports_to:report;
entity permanent_staff sub staff,
plays reports_to:manager;
entity temporary_staff @abstract, sub staff,
owns contract_end_date @card(1);
entity contractor sub temporary_staff;
entity intern sub temporary_staff;
Most notably, subtypes have their own structure. Contractors and interns inherit everything staff owns, and temporary_staff adds a required contract_end_date its parent doesn’t have. @abstract makes staff and temporary_staff pure classifications: only the non-abstract subtypes can be instantiated. Standard SQL has no inheritance; the relational model forces you to make compromises. You can read more about this in: Inheritance and polymorphism: where the cracks in SQL begin to show. In a property graph you can stack labels (:Staff:Temporary), but label co-occurrence is a convention, not an enforced hierarchy.
To truly take advantage of subtypes, notice that queries are polymorphic. match $s isa staff, has name $n; spans permanent staff, contractors, and interns in one pattern, no UNIONs, while $i isa intern still targets one type precisely. Type inference now works across the hierarchy too: asking for an intern’s contract_end_date is valid, and asking for a permanent staff member’s contract end date is a helpful read-time error.
In general, business requirements are now structural. Only permanent_staff plays manager, so a reporting line with a contractor as manager is rejected at write time. The requirement lives in the schema, not in a review comment.
Finally, cardinality is a range, not a flag. The rule “up to three phone numbers” is encoded as @card(0..3). SQL’s vocabulary stops at NOT NULL: zero-to-three needs a child table plus triggers, and “at least one” on the many side of a relationship is famously inexpressible. Property graphs typically have no relationship cardinality constraints.
A strictly constrained model
Some domains want more than guardrails. In role-based access control, finance, or clinical records, the goal is for invalid states to be unrepresentable: not caught in application code or a nightly audit, but impossible to write in the first place.
In TypeDB, we can use types in even more strict ways to get closer to this dream.
Enum types
Start with status – conceptually an enum record (one string from a list of possible strings). With the @values constraint, you protect against typos, but it’s still just data: the schema doesn’t know the three values are mutually exclusive states, and you can’t attach metadata to them. A TypeDB modelling trick we can leverage here is to promote the enum’s values into types, using unary relations (relations with a single role):
define
relation staff_status @abstract, relates subject;
relation active_status sub staff_status;
relation suspended_status sub staff_status;
relation terminated_status sub staff_status;
staff plays staff_status:subject @card(1);
Each status is now a type, and @card(1) on the plays keeps exactly one attached at a time. So far this only matches what owns status @card(1) already gave us.
define
attribute reason, value string;
attribute review_date, value date;
relation suspended_status,
owns reason @card(1),
owns review_date @card(1);
relation terminated_status,
owns reason @card(1);
Every state still inherits the single subject role from staff_status. But now, two of them have metadata. A termination must record a reason. A suspension must record a reason and a review date. An active status has no properties. Suspend someone without a review date and the transaction fails.
None of that is expressible with status as a string. A CHECK constraint can restrict a value, but it cannot say this value implies these two fields are mandatory and that one is forbidden. The typical SQL workaround is a nullable column per field per state, guarded by application code. TypeDB bakes the rule into the type system.
Queries stay polymorphic across the whole family. The following query returns all staff statuses and you can extract the actual status by checking the type of $s (Studio and Console print it, Driver SDKs include it in the query response object):
match
$s isa staff_status, links (subject: $x); # any status, any staff member
Or they can home in on one state to get its specific properties:
match
$s isa suspended_status, links (subject: $x), has review_date $d;
This is the database analogue of a sum type (a Rust enum, an ML datatype): a closed set of alternatives, each with its own payload and each type-checked.
This unary relation is a component rather than a fact about several parties. It allows us to attach orthogonal capabilities using composition instead of inheritance. TypeDB is single-inheritance, and staff has already used its sub axis to define the “permanent/temporary split”, so we add this unary relation, where all staff play staff_status:subject, yet exhibit different behaviours depending on which relation type is attached. The TypeDB schema modeling guide uses the same shape for a strongly typed HR subsystem.
Constraint hierarchies
Strict schemas also lean on the fact that constraints stack through the type hierarchy: every instance must satisfy all semantically applicable constraints, declared and inherited. From the TypeDB Academy:
define
attribute isbn @abstract, value string @regex("(.{10})|(.{13})");
attribute isbn_10 sub isbn, value string @regex(".{10}");
attribute isbn_13 sub isbn, value string @regex(".{13}");
entity book,
owns isbn @card(0..2),
owns isbn_10 @card(0..1),
owns isbn_13 @card(0..1);
Taken together, these declarations mean every book has zero, one, or two ISBNs in total, of which at most one is an ISBN-10 and at most one an ISBN-13, and each variant is pattern-checked against both its own regex and its parent’s. Try sketching that as CHECK constraints and triggers, or try it in Cypher.
Constraining sub-relations
Subtyping a relation inherits its roles. In an RBAC system modeling access grants to systems, an emergency_access_grant makes sense as a subtype of access_grant, but you probably want to tighten up the rules it needs to follow. Role specialization (relates ... as ...) achieves this:
define
relation access_grant,
relates grantee @card(1),
relates grantor @card(1..);
staff plays access_grant:grantee;
permanent_staff plays access_grant:grantor;
relation emergency_access_grant sub access_grant,
relates approver as grantor @card(2);
security_officer plays emergency_access_grant:approver;
Here we override / specialise access_grant:grantor to emergency_access_grant:approver. What does this mean? An ordinary staff member can no longer approve an emergency access grant – two security officers must sign off instead. And why bother with the relation type hierarchy? Well, this is because the systems themselves don’t care who approves access – they only care whether access should be granted or not. Thus a single, simple, polymorphic query that fetches access_grant records “just works”.
The subtype is more constrained than its parent, which is backwards from the usual database experience, where a special case means a new table, a nullable column, and a query that silently stops covering everything introducing a regression.
“Computed facts” using functions
One way to look at the power of a schema is: how many facts can I infer from the facts I have stored as actual records?
Polymorphism is one vehicle to achieve inference – the identity and behaviours of a supertype are implicitly attached to all instances of its subtypes.
Another such vehicle is TypeQL functions. They have various uses, but the particularly relevant one here is that you can define derived facts as functions, thus encoding new facts without ever persisting them to storage. In this case, we define the notion of teammates in an organisation:
define
fun teammates($member: staff) -> { staff }:
match
$_ isa reports_to, links (manager: $boss, report: $member);
$_ isa reports_to, links (manager: $boss, report: $teammate);
not { $teammate is $member; };
return { $teammate };
Two staff members are teammates when they report to the same manager. The fact is computed on demand, so it can never go stale. SQL views are the nearest relative. TypeQL functions offer typed signatures, composition inside any match, and recursion, making them excellent for representing computed facts.
So how strict should your schema be?
The “schemaless vs. schema-first” framing is too simplistic to express what we’ve just discussed. Schema-first is a spectrum, not a toggle. You can use a highly strict model in one part of your schema and a looser model elsewhere. Strict schema requires more upfront work. But bugs in production cost real money, time and business reputation, and one of the key goals of strict schema is to force those bugs to show up in development before they go live.
But you don’t need to go all-in on day one. You can increase or decrease strictness even on an existing database with live data; TypeDB will reject changes that lead to schema violation, whether those changes are schema or data.
Our own schema modeling guide calls it a trade-off between the size of the schema you’re willing to manage and the rigor with which your reads and writes are validated. Stricter typing results in errors ahead-of-time and richer queries, ideal for production systems; loose typing offers flexibility, well suited to prototyping and exploration.
The industry is visibly converging on the idea that structure belongs in the database, but we want to take this further. We believe databases should support highly robust structures. We believe this is a pathway to automatic correctness guarantees in the age of AI. But we also believe you should be able to start with a rough sketch and upgrade it into a bulletproof production application without painful, time-consuming migrations and restacks – to save you time and money in your maintenance cycle. TypeDB’s goal is to provide for users and businesses that share this desire for a database that “just works” all the way from prototype, to production, to year five.
