Chapter 12: Relational Modeling
Because Datalevin works well with normalized data [1], industry-standard Entity-Relationship (ER) modeling [2] is a useful starting point for designing your database.
SQL applications often use object-relational mapping (ORM) to bridge the gap between application objects and tables. Datalevin starts from facts instead: entities, attributes, and relationships are explicit datoms that can be queried directly. This chapter shows how to apply classic relational modeling patterns to a Datalog-powered triplestore.
1. The Grammar of Data: Nouns, Adjectives, and Verbs
When modeling a domain, think of your schema as a language.
1.1 Nouns (Entities)
Nouns are the "things" in your system: users, products, orders, invoices, courses, enrollments, and so on. In Datalevin, an entity is not represented by a table row, but by a set of facts that share one internal entity id. You usually describe that entity with a set of namespaced attributes such as :user/name, :user/gender, or :user/email.
Use singular attribute namespaces
Use singular nouns for attribute namespaces: :user/name, not :users/name; :product/price, not :products/price.
The namespace suggests which domain concept the attribute primarily describes. It is a modeling convention, not a table declaration or a class tag. If an entity has :user/email and :user/name, readers can recognize it as a user; Datalevin still stores ordinary datoms.
Singular namespacing makes Datalog queries feel like natural sentences: [?user-eid :user/name ?name] reads as "the entity ?user-eid has a user name ?name."
1.2 Adjectives (Attributes)
Adjectives are properties that describe an entity. These are the attributes already shown above. Attributes have value types: strings, longs, booleans, instants, keywords, and other types.
- Example:
:product/color "Red",:order/status :order.status/pending.
1.3 Verbs (Relationships)
Verbs describe how entities interact. In Datalevin, relationships are ordinary attributes whose value type is :db.type/ref.
- Example:
:user/follows,:order/customer,:line-item/product.
2. ER Design Decisions in Datalevin
ER modeling is useful because it forces you to make a few decisions explicitly before you write schema. Datalevin does not require tables, but the same questions still produce better facts. Normalize by default: store durable relationships as refs, and promote a relationship into its own entity when the relationship needs facts of its own.
2.1 Choose Stable Keys
Every important entity type should have a stable domain identifier when the domain has one. Use Datalevin's internal entity ids for storage and joins, but put natural identifiers in unique attributes so transactions, imports, and APIs can use lookup refs.
{:user/id {:db/valueType :db.type/string
:db/unique :db.unique/identity}
:product/sku {:db/valueType :db.type/string
:db/unique :db.unique/identity}
:invoice/uuid {:db/valueType :db.type/uuid
:db/unique :db.unique/identity}}
Schema identitySchema = Datalevin.schema()
.attr(":user/id", Schema.attribute()
.valueType(Schema.ValueType.STRING)
.unique(Schema.Unique.IDENTITY))
.attr(":product/sku", Schema.attribute()
.valueType(Schema.ValueType.STRING)
.unique(Schema.Unique.IDENTITY))
.attr(":invoice/uuid", Schema.attribute()
.valueType(Schema.ValueType.UUID)
.unique(Schema.Unique.IDENTITY));
identity_schema = {
":user/id": {":db/valueType": ":db.type/string",
":db/unique": ":db.unique/identity"},
":product/sku": {":db/valueType": ":db.type/string",
":db/unique": ":db.unique/identity"},
":invoice/uuid": {":db/valueType": ":db.type/uuid",
":db/unique": ":db.unique/identity"}}
const identitySchema = {
":user/id": {":db/valueType": ":db.type/string",
":db/unique": ":db.unique/identity"},
":product/sku": {":db/valueType": ":db.type/string",
":db/unique": ":db.unique/identity"},
":invoice/uuid": {":db/valueType": ":db.type/uuid",
":db/unique": ":db.unique/identity"}
};
Prefer stable identifiers such as account IDs, SKUs, slugs, or UUIDs. Avoid using mutable labels such as display names as identities unless the domain treats them as immutable.
2.2 Record Cardinality and Participation
ER diagrams distinguish one-to-one, one-to-many, and many-to-many relationships. Datalevin represents these with reference attributes and cardinality, but it does not enforce all ER participation constraints for you.
- One-to-many: Put a cardinality-one ref on the many side, such as
:order/useror:comment/post. - Many-to-many: Use a join entity when the relationship is queried directly, may grow large, or has attributes of its own.
- Optional participation: Omit the datom when the relationship is unknown or not applicable; do not invent sentinel values like
"N/A". - Required participation: Enforce it in transaction construction, application validation, or import checks. The schema documents the model, but a missing datom is still possible unless your write path rejects it.
Absence is a normal fact shape
A query for users without an active subscription is a query over missing relationship facts. nil/null is not a valid value in Datalevin. Optional relationships are omitted; required relationships need write-path validation or import checks.
2.3 Promote Relationships That Have Attributes
In ER modeling, a relationship can have attributes. In Datalevin, that is your signal to make the relationship an entity.
;; A thin model: the relationship has nowhere to put its own facts.
{:order/products [[:product/sku "SKU-1"] [:product/sku "SKU-2"]]}
;; A stronger model: each line item is a join entity.
{:line-item/order [:order/id "ord-1001"]
:line-item/product [:product/sku "SKU-1"]
:line-item/quantity 2
:line-item/price 1999}
;; The same pattern handles group membership.
{:membership/user [:user/id "u-101"]
:membership/group [:group/id "g-202"]
:membership/role :role/admin
:membership/joined-at #inst "2026-01-15T09:30:00.000-00:00"}
// A thin model: the relationship has nowhere to put its own facts.
Tx.entity()
.put(":order/products", List.of(
List.of(":product/sku", "SKU-1"),
List.of(":product/sku", "SKU-2")));
// A stronger model: each line item is a join entity.
Tx.entity()
.put(":line-item/order", List.of(":order/id", "ord-1001"))
.put(":line-item/product", List.of(":product/sku", "SKU-1"))
.put(":line-item/quantity", 2L)
.put(":line-item/price", 1999L);
// The same pattern handles group membership.
Tx.entity()
.put(":membership/user", List.of(":user/id", "u-101"))
.put(":membership/group", List.of(":group/id", "g-202"))
.put(":membership/role", Datalevin.kw(":role/admin"))
.put(":membership/joined-at", Instant.parse("2026-01-15T09:30:00Z"));
from datetime import datetime, timezone
from datalevin import keyword
# A thin model: the relationship has nowhere to put its own facts.
thin_order = {
":order/products": [
[":product/sku", "SKU-1"],
[":product/sku", "SKU-2"],
]
}
# A stronger model: each line item is a join entity.
line_item = {
":line-item/order": [":order/id", "ord-1001"],
":line-item/product": [":product/sku", "SKU-1"],
":line-item/quantity": 2,
":line-item/price": 1999,
}
# The same pattern handles group membership.
membership = {
":membership/user": [":user/id", "u-101"],
":membership/group": [":group/id", "g-202"],
":membership/role": keyword(":role/admin"),
":membership/joined-at": datetime(
2026, 1, 15, 9, 30, tzinfo=timezone.utc),
}
import { keyword } from "datalevin-node";
// A thin model: the relationship has nowhere to put its own facts.
const thinOrder = {
":order/products": [
[":product/sku", "SKU-1"],
[":product/sku", "SKU-2"]
]
};
// A stronger model: each line item is a join entity.
const lineItem = {
":line-item/order": [":order/id", "ord-1001"],
":line-item/product": [":product/sku", "SKU-1"],
":line-item/quantity": 2,
":line-item/price": 1999
};
// The same pattern handles group membership.
const membership = {
":membership/user": [":user/id", "u-101"],
":membership/group": [":group/id", "g-202"],
":membership/role": await keyword(":role/admin"),
":membership/joined-at": new Date("2026-01-15T09:30:00Z")
};
Join entities handle classic ER cases such as order line items, memberships, assignments, reservations, approvals, and permissions. They also handle ternary relationships cleanly: if a supplier sells a product to a store under a contract, model the contract as an entity with three refs, not as three separate binary links that lose the original meaning.
This is a modeling choice, not a raw datom-count optimization. Promote the relationship when it needs identity, attributes, lifecycle, uniqueness, direct queries, or selective filtering. Chapter 11, Section 2.2 covers the performance trade-offs between small :db.cardinality/many refs and join entities.
2.4 Model Weak Entities and Ownership
ER weak entities depend on an owner for identity or lifecycle. In Datalevin, model them as ordinary entities with a reference to the owner, and use :db/isComponent only when the child is truly owned by the parent and should be deleted with it.
Good component candidates include invoice line items, document sections, or profile settings that have no useful life outside the parent. Poor component candidates include users, products, organizations, or tags that can be shared or referenced independently.
Datalevin allows multiple parents to point at the same component child, but cascading retraction treats the child as owned data; use an ordinary reference for shared targets.
2.5 Represent Types Without Inheritance Tables
ER specialization maps naturally to facts. For a small closed set of subtypes, use an enum-style ref or :db/ident value:
{:account/id "acct-1"
:account/type :account.type/business
:account/name "Acme Corp"}
Tx.entity()
.put(":account/id", "acct-1")
.put(":account/type", Datalevin.kw(":account.type/business"))
.put(":account/name", "Acme Corp");
from datalevin import keyword
business_account = {
":account/id": "acct-1",
":account/type": keyword(":account.type/business"),
":account/name": "Acme Corp",
}
import { keyword } from "datalevin-node";
const businessAccount = {
":account/id": "acct-1",
":account/type": await keyword(":account.type/business"),
":account/name": "Acme Corp"
};
Use shared attributes in the common namespace and subtype-specific attributes only where they apply, such as :business-account/tax-id or :personal-account/birth-date. This keeps the model queryable without forcing an inheritance-table pattern into a fact database.
2.6 Be Deliberate About Derived Facts
ER analysis often identifies derived attributes, such as order totals, account balances, or comment counts. Prefer deriving these with Datalog when they are cheap and needed inside a transactionally current view. Store them only when they are expensive, part of an audit record, or must preserve a historical snapshot.
When you do store a derived fact, document the source of truth and update rule with :db/doc (Section 5). Future readers should be able to tell whether :order/total is authoritative, cached, or a historical snapshot.
3. Choosing Between Similar Shapes
Several Datalevin features can look like solutions to the same modeling problem. The right choice depends on whether you are modeling a relationship, an identity, or a value.
| Modeling problem | Prefer | Why |
|---|---|---|
| One entity points to one parent | Cardinality-one :db.type/ref on the child |
Normalized, simple to join, and available through reverse pull. |
| A small bounded owned set of refs or values | :db.cardinality/many |
Convenient only when the set stays small and is normally read with its owner; otherwise put a cardinality-one ref on the many side or use a join entity. |
| A relationship has attributes, lifecycle, uniqueness, or direct queries | Join entity | The relationship itself is a thing the domain talks about. |
| A combination of attributes identifies an entity | Composite unique identity with :db/tupleAttrs and :db/unique |
Datalevin maintains a derived lookup/index entry from ordinary attributes; this is the composite-uniqueness pattern. |
| A small vector-like value whose elements all have one type | Stored homogeneous tuple with :db/tupleType |
The tuple is one value, such as a coordinate or numeric interval. |
| A small record-like value with fixed positions of different types | Stored heterogeneous tuple with :db/tupleTypes |
The tuple is one value with typed positions, such as image bounds [x y width height]. |
| Arbitrary nested imported data | :db.type/idoc |
Use document modeling when the structure is open-ended or path-oriented; Chapter 14 covers idoc. |
3.1 Composite Tuple Attributes Are Access Paths
When users ask for a "custom index" in a Datalog database, they often mean: "I want to find or upsert an entity by several fields together." Use :db/tupleAttrs for that access path when the component fields are meaningful facts on their own and the combination needs identity or lookup behavior. Datalevin derives the tuple value from those component attributes; application code writes the components, not the derived tuple. Chapter 11, Section 1.2 shows the schema, transaction, and pull mechanics for composite lookup refs.
3.2 Stored Tuple Values Are Values
Stored tuple values are different. Use them when the tuple itself is a compact value object and its parts usually do not have useful meaning as standalone facts.
{:place/latlon {:db/valueType :db.type/tuple
:db/tupleType :db.type/double}
:image/bounds
{:db/valueType :db.type/tuple
:db/tupleTypes [:db.type/double
:db.type/double
:db.type/long
:db.type/long]}}
Schema tupleSchema = Datalevin.schema()
.attr(":place/latlon", Schema.attribute()
.valueType(Schema.ValueType.TUPLE)
.tupleType(Schema.ValueType.DOUBLE))
.attr(":image/bounds", Schema.attribute()
.valueType(Schema.ValueType.TUPLE)
.tupleTypes(Schema.ValueType.DOUBLE,
Schema.ValueType.DOUBLE,
Schema.ValueType.LONG,
Schema.ValueType.LONG));
tuple_schema = {
":place/latlon": {":db/valueType": ":db.type/tuple",
":db/tupleType": ":db.type/double"},
":image/bounds": {":db/valueType": ":db.type/tuple",
":db/tupleTypes": [":db.type/double",
":db.type/double",
":db.type/long",
":db.type/long"]}}
const tupleSchema = {
":place/latlon": {":db/valueType": ":db.type/tuple",
":db/tupleType": ":db.type/double"},
":image/bounds": {":db/valueType": ":db.type/tuple",
":db/tupleTypes": [":db.type/double",
":db.type/double",
":db.type/long",
":db.type/long"]}
};
:place/latlon is homogeneous: every element is a double. :image/bounds is heterogeneous: the first two positions are doubles and the last two are longs.
If you frequently ask "which places are north of this latitude?" or "which images are wider than this?", model those parts as ordinary attributes instead:
{:place/lat {:db/valueType :db.type/double}
:place/lon {:db/valueType :db.type/double}
:image/x {:db/valueType :db.type/double}
:image/y {:db/valueType :db.type/double}
:image/width {:db/valueType :db.type/long}
:image/height {:db/valueType :db.type/long}}
Schema separateSchema = Datalevin.schema()
.attr(":place/lat", Schema.attribute()
.valueType(Schema.ValueType.DOUBLE))
.attr(":place/lon", Schema.attribute()
.valueType(Schema.ValueType.DOUBLE))
.attr(":image/x", Schema.attribute()
.valueType(Schema.ValueType.DOUBLE))
.attr(":image/y", Schema.attribute()
.valueType(Schema.ValueType.DOUBLE))
.attr(":image/width", Schema.attribute()
.valueType(Schema.ValueType.LONG))
.attr(":image/height", Schema.attribute()
.valueType(Schema.ValueType.LONG));
separate_schema = {
":place/lat": {":db/valueType": ":db.type/double"},
":place/lon": {":db/valueType": ":db.type/double"},
":image/x": {":db/valueType": ":db.type/double"},
":image/y": {":db/valueType": ":db.type/double"},
":image/width": {":db/valueType": ":db.type/long"},
":image/height": {":db/valueType": ":db.type/long"}}
const separateSchema = {
":place/lat": {":db/valueType": ":db.type/double"},
":place/lon": {":db/valueType": ":db.type/double"},
":image/x": {":db/valueType": ":db.type/double"},
":image/y": {":db/valueType": ":db.type/double"},
":image/width": {":db/valueType": ":db.type/long"},
":image/height": {":db/valueType": ":db.type/long"}
};
Separate attributes give the query planner direct facts and indexes for each part. A stored tuple is best when the application normally treats the whole vector as one value and the tuple elements do not usually matter outside that value.
3.3 A Practical Decision Checklist
Ask these questions before reaching for a tuple:
- Is this a relationship between entities? Use refs, and use a join entity if the relationship has its own facts.
- Is this a uniqueness or lookup problem over several existing facts? Use
:db/tupleAttrs. - Is this one compact value whose components do not have useful meaning as standalone facts? Use a stored tuple.
- Are all stored tuple elements the same type? Use
:db/tupleType. - Do the stored tuple positions have different types and fixed meanings? Use
:db/tupleTypes. - Do you need to query, validate, document, or evolve the parts separately? Prefer separate attributes.
The short version: relationships should remain facts, composite identities should be derived from facts, and stored tuples should be reserved for values.
4. A Worked ER Example: Course Enrollment
Consider a small school registration system. In an ER diagram you would likely draw several stable domain entities, one course offering, and one join entity:
Student: identified by a student id.Course: identified by a course code.Term: identified by an academic term id.Instructor: identified by an instructor id.CourseOffering: a particular section of a course in a term, taught by an instructor.Enrollment: the fact that a student is enrolled in a course offering.
Here, a course offering is a scheduled section: a reusable catalog course such as CS101, offered in a particular term, with a section number, instructor, and capacity. It is not a special Datalevin construct. It is a contextual entity, because students enroll in that specific offering rather than directly in the catalog course.
Figure 12.1 is an ER diagram showing the relationships among the entities. The figure labels CourseOffering as a scheduled section to make that modeling role visible.
The important modeling move is the same one the figure highlights: Enrollment points to the course offering, not directly to the catalog course. The enrollment has attributes of its own: status, grade, enrollment time, and perhaps the source system that created the record. That makes it a join entity in Datalevin. Listing 12.1 shows the actual schema.
(def school-schema
{:student/id {:db/valueType :db.type/string
:db/unique :db.unique/identity}
:student/name {:db/valueType :db.type/string}
:course/code {:db/valueType :db.type/string
:db/unique :db.unique/identity}
:course/title {:db/valueType :db.type/string}
:term/id {:db/valueType :db.type/string
:db/unique :db.unique/identity}
:term/starts-at {:db/valueType :db.type/instant}
:instructor/id {:db/valueType :db.type/string
:db/unique :db.unique/identity}
:instructor/name {:db/valueType :db.type/string}
;; A scheduled section of a catalog course.
:course-offering/id {:db/valueType :db.type/string
:db/unique :db.unique/identity}
:course-offering/course {:db/valueType :db.type/ref}
:course-offering/term {:db/valueType :db.type/ref}
:course-offering/instructor {:db/valueType :db.type/ref}
:course-offering/section {:db/valueType :db.type/string}
:course-offering/capacity {:db/valueType :db.type/long}
;; Optional: enforce one offering per course/term/section.
:course-offering/key {:db/tupleAttrs [:course-offering/course
:course-offering/term
:course-offering/section]
:db/unique :db.unique/identity
:db/doc "Uniqueness key for one scheduled course section."}
;; Enrollment is the relationship between a student and an offering.
:enrollment/student {:db/valueType :db.type/ref}
:enrollment/offering {:db/valueType :db.type/ref}
;; Optional: enforce one enrollment per student/offering.
:enrollment/key {:db/tupleAttrs [:enrollment/student
:enrollment/offering]
:db/unique :db.unique/identity
:db/doc "Uniqueness key for one student's enrollment in one offering."}
:enrollment/status {:db/valueType :db.type/keyword
:db/doc "Current lifecycle state of the enrollment."}
:enrollment/grade {:db/valueType :db.type/string}
:enrollment/enrolled-at {:db/valueType :db.type/instant}})
Schema schoolSchema = Datalevin.schema()
.attr(":student/id", Schema.attribute()
.valueType(Schema.ValueType.STRING)
.unique(Schema.Unique.IDENTITY))
.attr(":student/name", Schema.attribute()
.valueType(Schema.ValueType.STRING))
.attr(":course/code", Schema.attribute()
.valueType(Schema.ValueType.STRING)
.unique(Schema.Unique.IDENTITY))
.attr(":course/title", Schema.attribute()
.valueType(Schema.ValueType.STRING))
.attr(":term/id", Schema.attribute()
.valueType(Schema.ValueType.STRING)
.unique(Schema.Unique.IDENTITY))
.attr(":term/starts-at", Schema.attribute()
.valueType(Schema.ValueType.INSTANT))
.attr(":instructor/id", Schema.attribute()
.valueType(Schema.ValueType.STRING)
.unique(Schema.Unique.IDENTITY))
.attr(":instructor/name", Schema.attribute()
.valueType(Schema.ValueType.STRING))
.attr(":course-offering/id", Schema.attribute()
.valueType(Schema.ValueType.STRING)
.unique(Schema.Unique.IDENTITY))
.attr(":course-offering/course", Schema.attribute()
.valueType(Schema.ValueType.REF))
.attr(":course-offering/term", Schema.attribute()
.valueType(Schema.ValueType.REF))
.attr(":course-offering/instructor", Schema.attribute()
.valueType(Schema.ValueType.REF))
.attr(":course-offering/section", Schema.attribute()
.valueType(Schema.ValueType.STRING))
.attr(":course-offering/capacity", Schema.attribute()
.valueType(Schema.ValueType.LONG))
.attr(":course-offering/key", Schema.attribute()
.tupleAttrs(":course-offering/course",
":course-offering/term",
":course-offering/section")
.unique(Schema.Unique.IDENTITY)
.doc("Uniqueness key for one scheduled course section."))
.attr(":enrollment/student", Schema.attribute()
.valueType(Schema.ValueType.REF))
.attr(":enrollment/offering", Schema.attribute()
.valueType(Schema.ValueType.REF))
.attr(":enrollment/key", Schema.attribute()
.tupleAttrs(":enrollment/student", ":enrollment/offering")
.unique(Schema.Unique.IDENTITY)
.doc("Uniqueness key for one student's enrollment in one offering."))
.attr(":enrollment/status", Schema.attribute()
.valueType(Schema.ValueType.KEYWORD)
.doc("Current lifecycle state of the enrollment."))
.attr(":enrollment/grade", Schema.attribute()
.valueType(Schema.ValueType.STRING))
.attr(":enrollment/enrolled-at", Schema.attribute()
.valueType(Schema.ValueType.INSTANT));
school_schema = {
":student/id": {":db/valueType": ":db.type/string",
":db/unique": ":db.unique/identity"},
":student/name": {":db/valueType": ":db.type/string"},
":course/code": {":db/valueType": ":db.type/string",
":db/unique": ":db.unique/identity"},
":course/title": {":db/valueType": ":db.type/string"},
":term/id": {":db/valueType": ":db.type/string",
":db/unique": ":db.unique/identity"},
":term/starts-at": {":db/valueType": ":db.type/instant"},
":instructor/id": {":db/valueType": ":db.type/string",
":db/unique": ":db.unique/identity"},
":instructor/name": {":db/valueType": ":db.type/string"},
":course-offering/id": {":db/valueType": ":db.type/string",
":db/unique": ":db.unique/identity"},
":course-offering/course": {":db/valueType": ":db.type/ref"},
":course-offering/term": {":db/valueType": ":db.type/ref"},
":course-offering/instructor": {":db/valueType": ":db.type/ref"},
":course-offering/section": {":db/valueType": ":db.type/string"},
":course-offering/capacity": {":db/valueType": ":db.type/long"},
":course-offering/key": {
":db/tupleAttrs": [":course-offering/course",
":course-offering/term",
":course-offering/section"],
":db/unique": ":db.unique/identity",
":db/doc": "Uniqueness key for one scheduled course section."},
":enrollment/student": {":db/valueType": ":db.type/ref"},
":enrollment/offering": {":db/valueType": ":db.type/ref"},
":enrollment/key": {
":db/tupleAttrs": [":enrollment/student", ":enrollment/offering"],
":db/unique": ":db.unique/identity",
":db/doc": "Uniqueness key for one student's enrollment in one offering."},
":enrollment/status": {":db/valueType": ":db.type/keyword",
":db/doc": "Current lifecycle state of the enrollment."},
":enrollment/grade": {":db/valueType": ":db.type/string"},
":enrollment/enrolled-at": {":db/valueType": ":db.type/instant"}}
const schoolSchema = {
":student/id": {":db/valueType": ":db.type/string",
":db/unique": ":db.unique/identity"},
":student/name": {":db/valueType": ":db.type/string"},
":course/code": {":db/valueType": ":db.type/string",
":db/unique": ":db.unique/identity"},
":course/title": {":db/valueType": ":db.type/string"},
":term/id": {":db/valueType": ":db.type/string",
":db/unique": ":db.unique/identity"},
":term/starts-at": {":db/valueType": ":db.type/instant"},
":instructor/id": {":db/valueType": ":db.type/string",
":db/unique": ":db.unique/identity"},
":instructor/name": {":db/valueType": ":db.type/string"},
":course-offering/id": {":db/valueType": ":db.type/string",
":db/unique": ":db.unique/identity"},
":course-offering/course": {":db/valueType": ":db.type/ref"},
":course-offering/term": {":db/valueType": ":db.type/ref"},
":course-offering/instructor": {":db/valueType": ":db.type/ref"},
":course-offering/section": {":db/valueType": ":db.type/string"},
":course-offering/capacity": {":db/valueType": ":db.type/long"},
":course-offering/key": {
":db/tupleAttrs": [":course-offering/course",
":course-offering/term",
":course-offering/section"],
":db/unique": ":db.unique/identity",
":db/doc": "Uniqueness key for one scheduled course section."},
":enrollment/student": {":db/valueType": ":db.type/ref"},
":enrollment/offering": {":db/valueType": ":db.type/ref"},
":enrollment/key": {
":db/tupleAttrs": [":enrollment/student", ":enrollment/offering"],
":db/unique": ":db.unique/identity",
":db/doc": "Uniqueness key for one student's enrollment in one offering."},
":enrollment/status": {":db/valueType": ":db.type/keyword",
":db/doc": "Current lifecycle state of the enrollment."},
":enrollment/grade": {":db/valueType": ":db.type/string"},
":enrollment/enrolled-at": {":db/valueType": ":db.type/instant"}
};
The refs are the relationship model. A course offering points to the course, term, and instructor entities. An enrollment points to the student and offering entities. Ordinary joins recover the corresponding domain identifiers when needed. There is no need to copy :course/code, :term/id, or :instructor/id onto the enrollment only to make later queries convenient. If you need uniqueness for either relationship, derive a composite tuple from the refs themselves.
(d/transact! conn
[{:student/id "s-100" :student/name "Ada"}
{:course/code "CS101" :course/title "Intro to Databases"}
{:term/id "2026-fall"}
{:instructor/id "i-10" :instructor/name "Grace Hopper"}
{:course-offering/id "2026-fall-CS101-001"
:course-offering/course [:course/code "CS101"]
:course-offering/term [:term/id "2026-fall"]
:course-offering/instructor [:instructor/id "i-10"]
:course-offering/section "001"
:course-offering/capacity 30}
{:enrollment/student [:student/id "s-100"]
:enrollment/offering [:course-offering/id "2026-fall-CS101-001"]
:enrollment/status :enrollment.status/active}])
Object courseRef = Datalevin.listOf(Datalevin.kw(":course/code"), "CS101");
Object termRef = Datalevin.listOf(Datalevin.kw(":term/id"), "2026-fall");
Object instructorRef = Datalevin.listOf(Datalevin.kw(":instructor/id"), "i-10");
Object offeringRef = Datalevin.listOf(
Datalevin.kw(":course-offering/id"),
"2026-fall-CS101-001");
conn.transact(Datalevin.tx()
.entity(Tx.entity()
.put(":student/id", "s-100")
.put(":student/name", "Ada"))
.entity(Tx.entity()
.put(":course/code", "CS101")
.put(":course/title", "Intro to Databases"))
.entity(Tx.entity()
.put(":term/id", "2026-fall"))
.entity(Tx.entity()
.put(":instructor/id", "i-10")
.put(":instructor/name", "Grace Hopper"))
.entity(Tx.entity()
.put(":course-offering/id", "2026-fall-CS101-001")
.put(":course-offering/course", courseRef)
.put(":course-offering/term", termRef)
.put(":course-offering/instructor", instructorRef)
.put(":course-offering/section", "001")
.put(":course-offering/capacity", 30L))
.entity(Tx.entity()
.put(":enrollment/student",
Datalevin.listOf(Datalevin.kw(":student/id"), "s-100"))
.put(":enrollment/offering", offeringRef)
.put(":enrollment/status",
Datalevin.kw(":enrollment.status/active"))));
from datalevin import interop
kw = interop().keyword
conn.transact([
{":student/id": "s-100", ":student/name": "Ada"},
{":course/code": "CS101", ":course/title": "Intro to Databases"},
{":term/id": "2026-fall"},
{":instructor/id": "i-10", ":instructor/name": "Grace Hopper"},
{":course-offering/id": "2026-fall-CS101-001",
":course-offering/course": [kw(":course/code"), "CS101"],
":course-offering/term": [kw(":term/id"), "2026-fall"],
":course-offering/instructor": [kw(":instructor/id"), "i-10"],
":course-offering/section": "001",
":course-offering/capacity": 30},
{":enrollment/student": [kw(":student/id"), "s-100"],
":enrollment/offering": [kw(":course-offering/id"),
"2026-fall-CS101-001"],
":enrollment/status": kw(":enrollment.status/active")}])
import { interop } from "datalevin-node";
const raw = interop();
const courseCode = await raw.keyword(":course/code");
const termId = await raw.keyword(":term/id");
const instructorId = await raw.keyword(":instructor/id");
const studentId = await raw.keyword(":student/id");
const offeringId = await raw.keyword(":course-offering/id");
const active = await raw.keyword(":enrollment.status/active");
await conn.transact([
{":student/id": "s-100", ":student/name": "Ada"},
{":course/code": "CS101", ":course/title": "Intro to Databases"},
{":term/id": "2026-fall"},
{":instructor/id": "i-10", ":instructor/name": "Grace Hopper"},
{":course-offering/id": "2026-fall-CS101-001",
":course-offering/course": [courseCode, "CS101"],
":course-offering/term": [termId, "2026-fall"],
":course-offering/instructor": [instructorId, "i-10"],
":course-offering/section": "001",
":course-offering/capacity": 30},
{":enrollment/student": [studentId, "s-100"],
":enrollment/offering": [offeringId, "2026-fall-CS101-001"],
":enrollment/status": active}
]);
To update that enrollment later, find the join entity by joining through the domain identifiers. The composite :enrollment/key still enforces one student/offering pair, but its tuple stores the resolved entity ids of those two refs. Code that starts with domain identifiers such as "s-100" and "CS101" therefore joins through those identifiers first instead of using [:enrollment/key ...] directly.
The query moves from course and term to the offering, then from offering and student to enrollment:
(let [enrollment
(d/q '[:find ?enrollment .
:in $ ?student-id ?course-code ?term-id ?section
:where
[?student :student/id ?student-id]
[?course :course/code ?course-code]
[?term :term/id ?term-id]
[?offering :course-offering/course ?course]
[?offering :course-offering/term ?term]
[?offering :course-offering/section ?section]
[?enrollment :enrollment/student ?student]
[?enrollment :enrollment/offering ?offering]]
(d/db conn)
"s-100"
"CS101"
"2026-fall"
"001")]
(d/transact! conn
[[:db/add enrollment :enrollment/grade "A"]]))
Object enrollment = conn.query(
"[:find ?enrollment . " +
" :in $ ?student-id ?course-code ?term-id ?section " +
" :where [?student :student/id ?student-id] " +
" [?course :course/code ?course-code] " +
" [?term :term/id ?term-id] " +
" [?offering :course-offering/course ?course] " +
" [?offering :course-offering/term ?term] " +
" [?offering :course-offering/section ?section] " +
" [?enrollment :enrollment/student ?student] " +
" [?enrollment :enrollment/offering ?offering]]",
"s-100",
"CS101",
"2026-fall",
"001");
conn.transact(Datalevin.tx()
.add(enrollment, ":enrollment/grade", "A"));
enrollment = conn.query("""
[:find ?enrollment .
:in $ ?student-id ?course-code ?term-id ?section
:where
[?student :student/id ?student-id]
[?course :course/code ?course-code]
[?term :term/id ?term-id]
[?offering :course-offering/course ?course]
[?offering :course-offering/term ?term]
[?offering :course-offering/section ?section]
[?enrollment :enrollment/student ?student]
[?enrollment :enrollment/offering ?offering]]
""", "s-100", "CS101", "2026-fall", "001")
conn.transact([[":db/add", enrollment, ":enrollment/grade", "A"]])
const enrollment = await conn.query(
`[:find ?enrollment .
:in $ ?student-id ?course-code ?term-id ?section
:where
[?student :student/id ?student-id]
[?course :course/code ?course-code]
[?term :term/id ?term-id]
[?offering :course-offering/course ?course]
[?offering :course-offering/term ?term]
[?offering :course-offering/section ?section]
[?enrollment :enrollment/student ?student]
[?enrollment :enrollment/offering ?offering]]`,
"s-100",
"CS101",
"2026-fall",
"001");
await conn.transact([[":db/add", enrollment, ":enrollment/grade", "A"]]);
The read query uses the same ref edges:
(d/q '[:find ?student-name ?course-title ?section ?instructor-name ?status
:in $ ?term-id
:where
[?term :term/id ?term-id]
[?offering :course-offering/term ?term]
[?offering :course-offering/course ?course]
[?offering :course-offering/section ?section]
[?offering :course-offering/instructor ?instructor]
[?enrollment :enrollment/offering ?offering]
[?enrollment :enrollment/student ?student]
[?enrollment :enrollment/status ?status]
[?student :student/name ?student-name]
[?course :course/title ?course-title]
[?instructor :instructor/name ?instructor-name]]
(d/db conn)
"2026-fall")
Object enrollments = conn.query(
"[:find ?student-name ?course-title ?section ?instructor-name ?status " +
" :in $ ?term-id " +
" :where [?term :term/id ?term-id] " +
" [?offering :course-offering/term ?term] " +
" [?offering :course-offering/course ?course] " +
" [?offering :course-offering/section ?section] " +
" [?offering :course-offering/instructor ?instructor] " +
" [?enrollment :enrollment/offering ?offering] " +
" [?enrollment :enrollment/student ?student] " +
" [?enrollment :enrollment/status ?status] " +
" [?student :student/name ?student-name] " +
" [?course :course/title ?course-title] " +
" [?instructor :instructor/name ?instructor-name]]",
"2026-fall");
enrollments = conn.query("""
[:find ?student-name ?course-title ?section ?instructor-name ?status
:in $ ?term-id
:where
[?term :term/id ?term-id]
[?offering :course-offering/term ?term]
[?offering :course-offering/course ?course]
[?offering :course-offering/section ?section]
[?offering :course-offering/instructor ?instructor]
[?enrollment :enrollment/offering ?offering]
[?enrollment :enrollment/student ?student]
[?enrollment :enrollment/status ?status]
[?student :student/name ?student-name]
[?course :course/title ?course-title]
[?instructor :instructor/name ?instructor-name]]
""", "2026-fall")
const enrollments = await conn.query(
`[:find ?student-name ?course-title ?section ?instructor-name ?status
:in $ ?term-id
:where
[?term :term/id ?term-id]
[?offering :course-offering/term ?term]
[?offering :course-offering/course ?course]
[?offering :course-offering/section ?section]
[?offering :course-offering/instructor ?instructor]
[?enrollment :enrollment/offering ?offering]
[?enrollment :enrollment/student ?student]
[?enrollment :enrollment/status ?status]
[?student :student/name ?student-name]
[?course :course/title ?course-title]
[?instructor :instructor/name ?instructor-name]]`,
"2026-fall");
This example illustrates the layers that often appear together in a real model:
- Domain entities such as students, courses, terms, and instructors.
- Scheduled or contextual entities such as course offerings, reservations, shipments, or price lists. These are real things in the domain, not merely attributes copied onto another entity.
- Join entities such as enrollments, line items, memberships, and assignments.
- Composite identities for join entities that need upsert, lookup, or import stability.
Use composite identities as constraints and access paths, not as a reason to duplicate facts. When the relationship is already expressed by refs, Datalevin's joins are the natural way to move from external domain keys to the relationship entity.
5. Documenting the Schema: :db/doc
Schema is maintenance documentation
A schema is for both the database engine and the developers who will maintain the system for years to come. Important attributes should be documented, especially when their meaning is not obvious from the name alone.
Datalevin supports a :db/doc property in the schema map. Use it to explain the purpose and constraints of an attribute.
(def schema
{:user/email {:db/valueType :db.type/string
:db/unique :db.unique/identity
:db/doc "The primary unique identifier for a user account."}
:order/total {:db/valueType :db.type/bigdec
:db/doc "The total price of the order in USD, including tax."}})
Schema schema = Datalevin.schema()
.attr(":user/email",
Schema.attribute()
.valueType(Schema.ValueType.STRING)
.unique(Schema.Unique.IDENTITY)
.doc("The primary unique identifier for a user account."))
.attr(":order/total",
Schema.attribute()
.valueType(Schema.ValueType.BIGDEC)
.doc("The total price of the order in USD, including tax."));
schema = {
":user/email": {":db/valueType": ":db.type/string",
":db/unique": ":db.unique/identity",
":db/doc": "The primary unique identifier for a user account."},
":order/total": {":db/valueType": ":db.type/bigdec",
":db/doc": "The total price of the order in USD, including tax."}}
const schema = {
":user/email": {":db/valueType": ":db.type/string",
":db/unique": ":db.unique/identity",
":db/doc": "The primary unique identifier for a user account."},
":order/total": {":db/valueType": ":db.type/bigdec",
":db/doc": "The total price of the order in USD, including tax."}};
Think of :db/doc as "comments that live in the database." They can be queried and used to generate documentation automatically.
This matters even more when LLMs or other tools help write queries. SQL databases usually attach comments to table and column objects, and the exact mechanism differs by vendor. Datalevin attaches documentation to the attribute itself. The same attribute name that appears in a Datalog clause can carry its meaning, constraints, and domain intent in schema metadata. A query assistant can inspect :db/doc before choosing between attributes such as :order/total, :order/subtotal, and :order/tax, instead of guessing from names alone. Good :db/doc strings therefore improve both human maintenance and machine-assisted query generation.
6. From Tables to Facts: Migrating SQL Models
Migrating from SQL to Datalevin is not a rejection of relational thinking. Datalevin keeps the relational principle that facts can be joined by shared values, but represents data as explicit datoms that can be joined, traversed, pulled, counted, sampled, and indexed in several ways.
When a SQL database adds JSON, text search, vector indexes, or graph-like extensions, it may reduce the number of services you run, but the application is still written against tables and rows as the primary interface. Datalevin starts from facts and uses Datalog as the surface language, so migration is not merely a change of syntax; it is a change in what the database exposes as its basic unit of reasoning.
Figure 12.2 shows the conceptual steps that are often involved in such a migration.
6.1 Translate the Vocabulary
The familiar SQL concepts still have Datalevin equivalents, but the boundaries move from tables to attributes and facts:
| SQL Concept | Datalevin Equivalent | Migration Note |
|---|---|---|
| Table | Namespace such as :order/ |
A namespace groups attributes by domain meaning; it is not a physical table. |
| Row | entity id | An entity is the set of facts that share the same entity id. |
| Column | Attribute | Attributes are schema objects with their own value type, cardinality, uniqueness, and index settings. |
| Primary Key | Unique identity attribute | Use stable domain IDs with :db.unique/identity; keep Datalevin entity ids internal. |
| Foreign Key | Ref attribute | A :db.type/ref stores another entity id and joins naturally in Datalog. |
| Join Table | Join entity | Model the join as its own entity when the relationship is large, queried directly, or has attributes. |
| NULL | No datom | Datalevin does not store nil/null; absence means the fact is not present. |
6.2 Decompose Rows into Facts
In SQL, a row is the unit that receives a full set of column values:
INSERT INTO orders (order_id, customer_id, status, total_cents, discount_code)
VALUES ('ord-1001', 'cust-42', 'paid', 4299, NULL);
In Datalevin, the transaction can still be written as an entity map, but the stored representation is a set of separate facts. The unknown discount_code is left out of the map rather than set to SQL NULL:
{:order/id "ord-1001"
:order/customer [:customer/id "cust-42"]
:order/status :order.status/paid
:order/total-cents 4299}
Conceptually, after lookup refs are resolved, the database contains datoms like:
[2001 :order/id "ord-1001"]
[2001 :order/customer 1001]
[2001 :order/status :order.status/paid]
[2001 :order/total-cents 4299]
Notice there is no :order/discount-code datom at all. Where SQL stores a NULL in the discount_code cell, Datalevin stores nothing: the fact is simply absent. This is why sparse and evolving domains work well in Datalevin. Optional fields do not require nullable columns. If a discount code, shipment, or cancellation reason is unknown, the corresponding datom is absent.
6.3 Recast Joins as Shared Variables
The most visible change for SQL developers is query syntax. SQL names tables and join conditions explicitly:
SELECT o.order_id, c.email
FROM orders o
JOIN customers c ON o.customer_id = c.customer_id
WHERE o.status = 'paid';
Datalog describes the facts that must be true. The join happens because ?customer-eid is shared by the order and customer patterns:
[:find ?order-id ?email
:where [?order-eid :order/id ?order-id]
[?order-eid :order/status :order.status/paid]
[?order-eid :order/customer ?customer-eid]
[?customer-eid :customer/email ?email]]
This is the same relational idea expressed declaratively. There is no JOIN keyword because unification over shared variables is the join.
Aggregations follow the same principle. SQL uses GROUP BY; Datalog groups by the non-aggregate variables in :find:
[:find ?status (count ?order-eid)
:where [?order-eid :order/status ?status]]
6.4 Model Foreign Keys and Join Tables Deliberately
One-to-many relationships map directly to a reference on the "many" side, such as :order/customer or :comment/post.
Many-to-many relationships require a choice:
- Use a cardinality-many ref only when the set is small, bounded, directly owned by the entity, and has no attributes of its own.
- Put a cardinality-one ref on the many side when that represents the relationship directly.
- Use a join entity when the relationship can grow large, needs facts, lifecycle, uniqueness, or direct queries.
For SQL migrations, join tables usually become join entities. A user_groups table with user_id, group_id, role, and joined_at is more than a link; it is a membership entity.
6.5 Use a Practical Migration Checklist
For an existing SQL application, migrate in this order:
- Map each table to a namespace and each column to an attribute.
- Preserve stable primary keys as
:db.unique/identityattributes. - Convert foreign keys to
:db.type/refattributes that point to lookup refs. - Convert join tables with payload columns into join entities.
- Treat SQL
NULLas an omitted datom by default; revisit only sentinel values or columns whereNULLcarried domain-specific meaning. - Add
:db/docto attributes whose meaning came from SQL constraints, comments, or application conventions. - Validate imports by comparing row counts, relationship counts, uniqueness constraints, and representative business queries.
6.6 Example: A Normalized E-commerce Schema
Suppose the SQL source has four familiar tables: products, customers, orders, and line_items. A direct migration might be tempted to make an order entity contain a nested list of products, quantities, and prices. That would recreate an object graph, not a normalized relational model.
In Datalevin, keep the same conceptual separation that made the SQL model useful. Products and customers are stable domain entities. Orders are events or business records that point to customers. Line items are join entities: each one connects one order to one product and carries facts about that relationship, such as quantity. The result is still relational, but the joins are expressed as Datalog variable sharing over refs rather than SQL JOIN clauses over table names.
(def ecommerce-schema
{;; Noun: Product
:product/sku {:db/unique :db.unique/identity
:db/valueType :db.type/string}
:product/title {:db/fulltext true
:db/valueType :db.type/string}
:product/price {:db/valueType :db.type/long}
;; Noun: Customer
:customer/id {:db/unique :db.unique/identity
:db/valueType :db.type/string}
:customer/email {:db/unique :db.unique/identity
:db/valueType :db.type/string}
;; Noun: Order
:order/id {:db/unique :db.unique/identity
:db/valueType :db.type/string}
:order/customer {:db/valueType :db.type/ref}
;; Join entity: Line Item (joins Order and Product)
:line-item/order {:db/valueType :db.type/ref}
:line-item/product {:db/valueType :db.type/ref}
:line-item/quantity {:db/valueType :db.type/long}})
Schema ecommerceSchema = Datalevin.schema()
// Noun: Product
.attr(":product/sku",
Schema.attribute()
.unique(Schema.Unique.IDENTITY)
.valueType(Schema.ValueType.STRING))
.attr(":product/title",
Schema.attribute()
.fulltext(true)
.valueType(Schema.ValueType.STRING))
.attr(":product/price",
Schema.attribute()
.valueType(Schema.ValueType.LONG))
// Noun: Customer
.attr(":customer/id",
Schema.attribute()
.unique(Schema.Unique.IDENTITY)
.valueType(Schema.ValueType.STRING))
.attr(":customer/email",
Schema.attribute()
.unique(Schema.Unique.IDENTITY)
.valueType(Schema.ValueType.STRING))
// Noun: Order
.attr(":order/id",
Schema.attribute()
.unique(Schema.Unique.IDENTITY)
.valueType(Schema.ValueType.STRING))
.attr(":order/customer",
Schema.attribute()
.valueType(Schema.ValueType.REF))
// Join entity: Line Item (joins Order and Product)
.attr(":line-item/order",
Schema.attribute()
.valueType(Schema.ValueType.REF))
.attr(":line-item/product",
Schema.attribute()
.valueType(Schema.ValueType.REF))
.attr(":line-item/quantity",
Schema.attribute()
.valueType(Schema.ValueType.LONG));
ecommerce_schema = {
# Noun: Product
":product/sku": {":db/unique": ":db.unique/identity",
":db/valueType": ":db.type/string"},
":product/title": {":db/fulltext": True,
":db/valueType": ":db.type/string"},
":product/price": {":db/valueType": ":db.type/long"},
# Noun: Customer
":customer/id": {":db/unique": ":db.unique/identity",
":db/valueType": ":db.type/string"},
":customer/email": {":db/unique": ":db.unique/identity",
":db/valueType": ":db.type/string"},
# Noun: Order
":order/id": {":db/unique": ":db.unique/identity",
":db/valueType": ":db.type/string"},
":order/customer": {":db/valueType": ":db.type/ref"},
# Join entity: Line Item (joins Order and Product)
":line-item/order": {":db/valueType": ":db.type/ref"},
":line-item/product": {":db/valueType": ":db.type/ref"},
":line-item/quantity": {":db/valueType": ":db.type/long"}}
const ecommerceSchema = {
// Noun: Product
":product/sku": {":db/unique": ":db.unique/identity",
":db/valueType": ":db.type/string"},
":product/title": {":db/fulltext": true,
":db/valueType": ":db.type/string"},
":product/price": {":db/valueType": ":db.type/long"},
// Noun: Customer
":customer/id": {":db/unique": ":db.unique/identity",
":db/valueType": ":db.type/string"},
":customer/email": {":db/unique": ":db.unique/identity",
":db/valueType": ":db.type/string"},
// Noun: Order
":order/id": {":db/unique": ":db.unique/identity",
":db/valueType": ":db.type/string"},
":order/customer": {":db/valueType": ":db.type/ref"},
// Join entity: Line Item (joins Order and Product)
":line-item/order": {":db/valueType": ":db.type/ref"},
":line-item/product": {":db/valueType": ":db.type/ref"},
":line-item/quantity": {":db/valueType": ":db.type/long"}
};
The key decision is the :line-item/ namespace. A line item is not a product attribute and not an order attribute; it is the relationship between an order and a product. Modeling it as its own entity lets the application ask precise questions: which products appeared in paid orders, which customers bought a given SKU, how many units of each product were sold, or which orders contain backordered items. Those queries can start from whichever fact is selective and join through the two refs.
The schema also shows how migration preserves stable identifiers without exposing Datalevin entity ids. :product/sku, :customer/id, and :order/id are unique identity attributes, so imported rows and application commands can use lookup refs. :order/customer, :line-item/order, and :line-item/product store entity ids internally, but callers can transact them using the domain keys they already have.
This is intentionally only the starting point. A production schema might add order status, timestamps, shipment entities, payment attempts, discounts, inventory reservations, or a composite :line-item/key over order and product when the domain allows only one line per product per order. The modeling rule is the same: keep durable nouns as entities, represent foreign keys as refs, and promote relationships with their own facts into join entities.
Summary: Relational Best Practices
- Think in singular namespaces:
:user/email, not:users/emails. - Choose stable keys: Use
:db.unique/identityfor domain identifiers you will use in imports, APIs, and lookup refs. - Normalize relationships: Prefer many-side refs and join entities for large or directly queried relationships; see Chapter 11, Section 2.2.
- Model ownership carefully: Use
:db/isComponentfor true single-owner lifecycle ownership, not for convenient nesting alone. - Represent subtypes as facts: Use enum entities or subtype-specific namespaces rather than inheritance-table patterns.
- Use tuple features deliberately: Use
:db/tupleAttrsfor composite identity and stored tuple types only for compact value objects. - Document as you go: Use
:db/docto encode the "why" behind every attribute, especially derived or denormalized facts. - Migrate SQL incrementally: Preserve stable keys, convert foreign keys to refs, and validate migrated facts with the business queries the application already depends on.
By applying these ER principles, you keep your Datalevin database explicit, queryable, and understandable as your domain grows.
References
[1] E. F. Codd, "Further Normalization of the Data Base Relational Model," IBM Research Report RJ909, August 31, 1971. Republished in Randall J. Rustin, ed., Data Base Systems: Courant Computer Science Symposia Series 6, Prentice-Hall, 1972.
[2] Peter Pin-Shan Chen, "The Entity-Relationship Model: Toward a Unified View of Data," ACM Transactions on Database Systems 1(1):9-36, 1976. DOI: https://doi.org/10.1145/320434.320440.
User Examples
Log in to create examplesNo examples for this chapter yet.
