DatalevinDatalevin
Part III - Modeling Across Paradigms

Chapter 11: Schema Design

While Chapter 5 introduced the mechanics of attributes and namespaces, this chapter focuses on schema design choices. In a multi-paradigm database like Datalevin, schema is where you tell the query engine, search indexes, and storage layer how to interpret your facts.

A well-designed schema in Datalevin enables efficient joins, graph traversals, full-text search, embedding search, vector search, and path-indexed documents.

For a compact reference of every Datalog schema property and its accepted values, see Appendix C, "Datalog Schema Reference."

1. Identity: :db.unique/identity

One of the most important decisions in schema design is how you identify your entities. While Datalevin provides internal 64-bit integer ids, application code usually wants to refer to entities using natural keys from your domain (like an email, a SKU, a URL slug, or synthetic keys like a UUID).

1.1 Choosing Domain Identity Attributes

Chapter 7 shows how lookup refs are used when reading data. The schema-design question comes earlier: which attributes should be stable handles for logical entities?

Any attribute with :db/unique can be used in a lookup ref. For domain identifiers such as emails, SKUs, account numbers, URL slugs, or external ids, :db.unique/identity is usually the right choice. It says that the value identifies the entity, and it enables upsert behavior when a transaction map mentions an identity value that already exists.

Use :db.unique/identity for values that answer "which entity is this?" Use :db.unique/value for values where a duplicate is an error, not an instruction to merge transaction data into an existing entity.

(def schema
  {:user/email  {:db/valueType :db.type/string
                 :db/unique    :db.unique/identity}
   :sku/id      {:db/valueType :db.type/string
                 :db/unique    :db.unique/identity}
   :invite/code {:db/valueType :db.type/string
                 :db/unique    :db.unique/value}})
Schema schema = Datalevin.schema()
    .attr(":user/email", Schema.attribute()
        .valueType(Schema.ValueType.STRING)
        .unique(Schema.Unique.IDENTITY))
    .attr(":sku/id", Schema.attribute()
        .valueType(Schema.ValueType.STRING)
        .unique(Schema.Unique.IDENTITY))
    .attr(":invite/code", Schema.attribute()
        .valueType(Schema.ValueType.STRING)
        .unique(Schema.Unique.VALUE));
schema = {
    ":user/email": {":db/valueType": ":db.type/string",
                    ":db/unique": ":db.unique/identity"},
    ":sku/id": {":db/valueType": ":db.type/string",
                ":db/unique": ":db.unique/identity"},
    ":invite/code": {":db/valueType": ":db.type/string",
                     ":db/unique": ":db.unique/value"},
}
const schema = {
  ":user/email": {":db/valueType": ":db.type/string",
                  ":db/unique": ":db.unique/identity"},
  ":sku/id": {":db/valueType": ":db.type/string",
              ":db/unique": ":db.unique/identity"},
  ":invite/code": {":db/valueType": ":db.type/string",
                   ":db/unique": ":db.unique/value"}
};

Here :invite/code is not an upsert key for the invitation entity. If a new invitation tries to reuse an existing code, Datalevin rejects the transaction; the application can treat that as a collision or replay and generate another code. With :db.unique/identity, the same duplicate value would instead resolve to the existing entity and merge the rest of the transaction data into it.

If you transact a map with a unique identity that already exists in the database, Datalevin merges the new attributes into the existing entity instead of creating a duplicate. Chapter 6 covers upsert behavior in transaction context; Chapter 7 covers lookup refs as read inputs.

1.2 Composite Identity with Tuple Attributes

When people ask for a custom index in a Datalevin Datalog database, they often mean a composite lookup over several attributes. Datalevin's usual answer is a derived tuple attribute: define an attribute with :db/tupleAttrs, and Datalevin maintains a composite index entry from its component attributes.

This is different from the transacted tuple data type. With :db/tupleAttrs, the application writes the component attributes, and Datalevin maintains the derived tuple value in the normal Datalog indexes. It is a composite access path over ordinary attributes, not a tuple value that application code transacts directly. Transacted tuple values use :db.type/tuple with :db/tupleType or :db/tupleTypes; those forms are covered in Appendix C.

Even though application code does not write the derived tuple directly, auto-maintained composite tuples use the same tuple value encoding as transacted tuple values, so the same tuple-element limitations apply.

A line item, for example, may be identified by the pair of order id and SKU. The application writes :line-item/order-id and :line-item/sku; Datalevin makes :line-item/order+sku available as a derived composite lookup, as illustrated in Figure 11.1.

Composite identity from a derived tuple: a line-item entity stores the component attributes order-id and sku; Datalevin derives the tuple attribute :line-item/order+sku via :db/tupleAttrs, marks it unique, and never requires you to write it; the unique derived tuple then enables a composite lookup ref and upsert on the component values

(def schema
  {:line-item/order-id   {:db/valueType :db.type/string}
   :line-item/sku        {:db/valueType :db.type/string}
   :line-item/quantity   {:db/valueType :db.type/long}
   :line-item/order+sku  {:db/tupleAttrs [:line-item/order-id :line-item/sku]
                          :db/unique     :db.unique/identity}})
Schema schema = Datalevin.schema()
    .attr(":line-item/order-id", Schema.attribute()
        .valueType(Schema.ValueType.STRING))
    .attr(":line-item/sku", Schema.attribute()
        .valueType(Schema.ValueType.STRING))
    .attr(":line-item/quantity", Schema.attribute()
        .valueType(Schema.ValueType.LONG))
    .attr(":line-item/order+sku", Schema.attribute()
        .tupleAttrs(":line-item/order-id", ":line-item/sku")
        .unique(Schema.Unique.IDENTITY));
schema = {
    ":line-item/order-id": {":db/valueType": ":db.type/string"},
    ":line-item/sku": {":db/valueType": ":db.type/string"},
    ":line-item/quantity": {":db/valueType": ":db.type/long"},
    ":line-item/order+sku": {
        ":db/tupleAttrs": [":line-item/order-id", ":line-item/sku"],
        ":db/unique": ":db.unique/identity"}}
const schema = {
  ":line-item/order-id": {":db/valueType": ":db.type/string"},
  ":line-item/sku": {":db/valueType": ":db.type/string"},
  ":line-item/quantity": {":db/valueType": ":db.type/long"},
  ":line-item/order+sku": {
    ":db/tupleAttrs": [":line-item/order-id", ":line-item/sku"],
    ":db/unique": ":db.unique/identity"}
};

Now the composite key can be used anywhere a lookup ref can be used:

;; Create or update by component attributes. The tuple is maintained.
(d/transact! conn
  [{:line-item/order-id "o-1001"
    :line-item/sku      "SKU-42"
    :line-item/quantity 2}])

;; Update the same line item by composite lookup ref.
(d/transact! conn
  [[:db/add [:line-item/order+sku ["o-1001" "SKU-42"]]
            :line-item/quantity
            3]])

;; Pull by the same composite identity.
(d/pull (d/db conn)
        '[:line-item/sku :line-item/quantity]
        [:line-item/order+sku ["o-1001" "SKU-42"]])
// Create or update by component attributes. The tuple is maintained.
conn.transact(Datalevin.tx()
    .entity(Tx.entity()
        .put(":line-item/order-id", "o-1001")
        .put(":line-item/sku", "SKU-42")
        .put(":line-item/quantity", 2L)));

// Update the same line item by composite lookup ref.
conn.transact(Datalevin.tx()
    .add(List.of(":line-item/order+sku", List.of("o-1001", "SKU-42")),
         ":line-item/quantity",
         3L));

// Pull by the same composite identity.
Map<?, ?> item = conn.pull(
    "[:line-item/sku :line-item/quantity]",
    List.of(":line-item/order+sku", List.of("o-1001", "SKU-42")));
# Create or update by component attributes. The tuple is maintained.
conn.transact([{":line-item/order-id": "o-1001",
                ":line-item/sku": "SKU-42",
                ":line-item/quantity": 2}])

# Update the same line item by composite lookup ref.
conn.transact([[":db/add",
                [":line-item/order+sku", ["o-1001", "SKU-42"]],
                ":line-item/quantity",
                3]])

# Pull by the same composite identity.
item = conn.pull("[:line-item/sku :line-item/quantity]",
                 [":line-item/order+sku", ["o-1001", "SKU-42"]])
// Create or update by component attributes. The tuple is maintained.
await conn.transact([{":line-item/order-id": "o-1001",
                      ":line-item/sku": "SKU-42",
                      ":line-item/quantity": 2}]);

// Update the same line item by composite lookup ref.
await conn.transact([[":db/add",
                      [":line-item/order+sku", ["o-1001", "SKU-42"]],
                      ":line-item/quantity",
                      3]]);

// Pull by the same composite identity.
const item = await conn.pull("[:line-item/sku :line-item/quantity]",
                             [":line-item/order+sku", ["o-1001", "SKU-42"]]);

This pattern is useful for user-defined secondary access paths: tenant plus external id, account plus date, document plus section number, order plus SKU, or student plus course plus term. If the derived tuple attribute is also unique, it becomes a composite identity and participates in upsert.

Datalevin does not have a separate unique-composite schema property. A composite uniqueness constraint is expressed by combining :db/tupleAttrs with :db/unique on the derived tuple attribute. :db/tupleAttrs says how to derive the composite value from ordinary attributes; :db/unique says whether that derived value is a unique identity or unique value.

A maintained access path trades write maintenance for direct reads. A derived tuple attribute gives Datalevin an indexed value for the whole combination, so lookup refs and upsert by the composite key are direct. Writes that change any component must also maintain the derived tuple value. If you only need occasional ad hoc filtering by the components, ordinary Datalog joins over the component attributes may be enough. If the combination is a domain identity, an upsert key, or a frequent lookup path, prefer :db/tupleAttrs with :db/unique.

Do not use a transacted :db.type/tuple as a substitute for this unless the tuple itself is the application value. A stored tuple can be unique like any other attribute, but Datalevin will not derive it from separate component attributes or keep those components in sync for you.

There are a few important rules:

;; Build a tuple in the query and match it against the derived attribute.
(d/q '[:find ?line
       :where [?line :line-item/order-id ?order-id]
              [?line :line-item/sku ?sku]
              [(tuple ?order-id ?sku) ?order+sku]
              [?line :line-item/order+sku ?order+sku]]
     (d/db conn))

;; Destructure a tuple value back into its components.
(d/q '[:find ?line ?order-id ?sku
       :where [?line :line-item/order+sku ?order+sku]
              [(untuple ?order+sku) [?order-id ?sku]]]
     (d/db conn))
Object lines = conn.query(
    "[:find ?line " +
    " :where [?line :line-item/order-id ?order-id] " +
    "        [?line :line-item/sku ?sku] " +
    "        [(tuple ?order-id ?sku) ?order+sku] " +
    "        [?line :line-item/order+sku ?order+sku]]");

Object parts = conn.query(
    "[:find ?line ?order-id ?sku " +
    " :where [?line :line-item/order+sku ?order+sku] " +
    "        [(untuple ?order+sku) [?order-id ?sku]]]");
lines = conn.query("""
[:find ?line
 :where [?line :line-item/order-id ?order-id]
        [?line :line-item/sku ?sku]
        [(tuple ?order-id ?sku) ?order+sku]
        [?line :line-item/order+sku ?order+sku]]
""")

parts = conn.query("""
[:find ?line ?order-id ?sku
 :where [?line :line-item/order+sku ?order+sku]
        [(untuple ?order+sku) [?order-id ?sku]]]
""")
const lines = await conn.query(
  `[:find ?line
    :where [?line :line-item/order-id ?order-id]
           [?line :line-item/sku ?sku]
           [(tuple ?order-id ?sku) ?order+sku]
           [?line :line-item/order+sku ?order+sku]]`);

const parts = await conn.query(
  `[:find ?line ?order-id ?sku
    :where [?line :line-item/order+sku ?order+sku]
           [(untuple ?order+sku) [?order-id ?sku]]]`);

1.3 System-Wide Identifiers: :db/ident

While :db.unique/identity is used for attributes that uniquely identify a domain entity (like a user's email), :db/ident is a built-in attribute used to assign a globally unique keyword to an entity.

This is the standard way to represent enums or system-wide constants. Once an entity has a :db/ident, you can use that keyword anywhere you would use an entity id or a lookup ref.

;; Schema includes a ref attribute for the enum-valued field
(def schema {:order/id     {:db/valueType :db.type/string
                            :db/unique    :db.unique/identity}
             :order/status {:db/valueType :db.type/ref}})

;; Define an "enum" entity for order status
(d/transact! conn [{:db/ident :order.status/shipped}])

;; Use the keyword directly in another transaction
(d/transact! conn [{:order/id "123" :order/status :order.status/shipped}])
// Schema includes a ref attribute for the enum-valued field
Schema schema = Datalevin.schema()
    .attr(":order/id", Schema.attribute()
        .valueType(Schema.ValueType.STRING)
        .unique(Schema.Unique.IDENTITY))
    .attr(":order/status", Schema.attribute()
        .valueType(Schema.ValueType.REF));

// Define an "enum" entity for order status
conn.transact(Datalevin.tx()
    .entity(Tx.entity()
        .put(":db/ident", Datalevin.kw(":order.status/shipped"))));

// Use the keyword directly in another transaction
conn.transact(Datalevin.tx()
    .entity(Tx.entity()
        .put(":order/id", "123")
        .put(":order/status", Datalevin.kw(":order.status/shipped"))));
from datalevin import interop

kw = interop().keyword

# Schema includes a ref attribute for the enum-valued field
schema = {":order/id": {":db/valueType": ":db.type/string",
                        ":db/unique": ":db.unique/identity"},
          ":order/status": {":db/valueType": ":db.type/ref"}}

# Define an "enum" entity for order status
conn.transact([{":db/ident": kw(":order.status/shipped")}])

# Use the keyword directly in another transaction
conn.transact([{":order/id": "123",
                ":order/status": kw(":order.status/shipped")}])
import { interop } from "datalevin-node";

const raw = interop();
const shipped = await raw.keyword(":order.status/shipped");

// Schema includes a ref attribute for the enum-valued field
const schema = {
  ":order/id": {":db/valueType": ":db.type/string",
                ":db/unique": ":db.unique/identity"},
  ":order/status": {":db/valueType": ":db.type/ref"}
};

// Define an "enum" entity for order status
await conn.transact([{":db/ident": shipped}]);

// Use the keyword directly in another transaction
await conn.transact([{":order/id": "123", ":order/status": shipped}]);

The important point is that :order/status is a :db.type/ref, so it takes an entity id. Here a :db/ident serves the purpose of an entity id. Installing a :db/ident entity is an ordinary data write, not a schema change.

The advantage of using :db/ident over raw strings or keywords is that the enum itself is an ordinary entity. You can attach descriptive facts to it (like :order.status/label "Shipped") without changing your order data.

For example, later you can add display details to the status entity itself:

(d/transact! conn
  [{:db/ident :order.status/shipped
    :order.status/label "Shipped"
    :order.status/sort 30}])

(d/pull (d/db conn)
  [:db/ident :order.status/label :order.status/sort]
  :order.status/shipped)
;;=> {:db/ident :order.status/shipped
;;    :order.status/label "Shipped"
;;    :order.status/sort 30}
conn.transact(Datalevin.tx()
    .entity(Tx.entity()
        .put(":db/ident", Datalevin.kw(":order.status/shipped"))
        .put(":order.status/label", "Shipped")
        .put(":order.status/sort", 30L)));

Map<?, ?> status = conn.pull(
    "[:db/ident :order.status/label :order.status/sort]",
    Datalevin.kw(":order.status/shipped"));
// => {":db/ident": ":order.status/shipped",
//     ":order.status/label": "Shipped",
//     ":order.status/sort": 30}
from datalevin import interop

shipped_status = interop().keyword(":order.status/shipped")

conn.transact([{":db/ident": shipped_status,
                ":order.status/label": "Shipped",
                ":order.status/sort": 30}])

status = conn.pull(
    "[:db/ident :order.status/label :order.status/sort]",
    shipped_status)
# => {":db/ident": ":order.status/shipped",
#     ":order.status/label": "Shipped",
#     ":order.status/sort": 30}
import { interop } from "datalevin-node";

const raw = interop();
const shippedStatus = await raw.keyword(":order.status/shipped");

await conn.transact([{
  ":db/ident": shippedStatus,
  ":order.status/label": "Shipped",
  ":order.status/sort": 30
}]);

const status = await conn.pull(
  "[:db/ident :order.status/label :order.status/sort]",
  shippedStatus
);
// => { ":db/ident": ":order.status/shipped",
//      ":order.status/label": "Shipped",
//      ":order.status/sort": 30 }

Existing orders still point at the same status entity. You added facts about the enum; you did not rewrite the order facts that refer to it.

2. Modeling Relationships: References and Cardinality

In Datalevin, relationships are ordinary facts. They are defined using the :db.type/ref value type and the :db/cardinality property. The stored value of a reference attribute is the entity id of the target entity. In transaction data, you can still use tempids, lookup refs, or :db/ident keywords as convenient inputs; Datalevin resolves them to entity ids when it writes the datoms.

One of the most important decisions when modeling a relationship is its cardinality [1], also called association multiplicity: one-to-one, one-to-many, or many-to-many.

2.1 One-to-One and One-to-Many

The one-to-many example is worth stating carefully: you do not need a :post/comments cardinality-many attribute only to find a post's comments. Store :comment/post on each comment, then query or pull the reverse direction when you need the collection. The broader performance rationale for normalized relationship facts is covered in the many-to-many discussion below.

2.2 Many-to-Many: Cardinality vs. Join Entities

For many-to-many relationships, you have two main ways to model them, as compared in Figure 11.2.

  1. :db.cardinality/many: You can add an attribute with :db.cardinality/many to one or both entities. Use this only for small, bounded sets that are normally read with their owner. It is convenient and can produce a compact shape in d/pull results, but it is not the right default for large or queryable relationships.
  2. Join Entities: You can create a third entity that "joins" the other two, similar to a join table in SQL.

Many-to-many models compared: a :db.cardinality/many ref puts :user/roles holding role/admin and role/editor on user u-1 - convenient and pulls as one nested list, but each member is still a separate datom, the relationship cannot carry attributes, and large sets get costly; a join entity uses role-assignment entities linking u-1 to each role - normalized facts the optimizer can count, join, and filter, scaling to large relationships and able to carry edge attributes like assigned-at, validity, rank, or source

While :db.cardinality/many is convenient, it is not a compact array stored inside one datom. Each member is a separate datom, logically shaped like [entity attribute value], and it participates in the indexes. DUPSORT reduces some repeated-prefix cost at the storage layer, but a large many-valued attribute still carries real index-entry overhead and repeated entity/attribute structure. For large relationships, this shape behaves like non-normalized data and can impose a real index cost; Chapter 15 explains the EAV and AVE mechanics. Often the better model is to put a cardinality-one ref on the many side; when the relationship itself needs facts, use a join entity.

Datalevin performs best with normalized data [2]: small relationship facts whose access paths are explicit. For one-to-many relationships, this often means placing a cardinality-one reference on the many side. For many-to-many relationships, it often means creating a join entity.

This shape gives the query optimizer more granular facts to count, join, and filter. It also avoids treating a large relationship set as if it were a small property of one entity. If you expect a single entity to have hundreds, thousands, or millions of references, a join entity is the better default for query planning and operational clarity. This is not about minimizing raw datom count; a join entity usually stores more datoms than one cardinality-many ref. It is the right model when the relationship itself has attributes, such as role assignment time, quantity, rank, validity interval, or source system.

In the example below, :role/admin and :role/editor are :db/ident enum entities used as reference values, as described in Section 1.3.

;; Instead of many references in one entity:
{:user/id "u-1" :user/roles [:role/admin :role/editor]}

;; Use join entities for large or queryable relationships:
(d/transact! conn
  [{:role-assignment/user [:user/id "u-1"]
    :role-assignment/role :role/admin}
   {:role-assignment/user [:user/id "u-1"]
    :role-assignment/role :role/editor}])
// Instead of many references in one entity:
Tx.entity()
    .put(":user/id", "u-1")
    .put(":user/roles", List.of(Datalevin.kw(":role/admin"),
                               Datalevin.kw(":role/editor")));

// Use join entities for large or queryable relationships:
conn.transact(Datalevin.tx()
    .entity(Tx.entity()
        .put(":role-assignment/user", List.of(":user/id", "u-1"))
        .put(":role-assignment/role", Datalevin.kw(":role/admin")))
    .entity(Tx.entity()
        .put(":role-assignment/user", List.of(":user/id", "u-1"))
        .put(":role-assignment/role", Datalevin.kw(":role/editor"))));
from datalevin import interop

kw = interop().keyword

# Instead of many references in one entity:
many_roles = {":user/id": "u-1",
              ":user/roles": [kw(":role/admin"), kw(":role/editor")]}

# Use join entities for large or queryable relationships:
conn.transact([
    {":role-assignment/user": [":user/id", "u-1"],
     ":role-assignment/role": kw(":role/admin")},
    {":role-assignment/user": [":user/id", "u-1"],
     ":role-assignment/role": kw(":role/editor")}])
import { interop } from "datalevin-node";

const raw = interop();
const admin = await raw.keyword(":role/admin");
const editor = await raw.keyword(":role/editor");

// Instead of many references in one entity:
const manyRoles = {":user/id": "u-1", ":user/roles": [admin, editor]};

// Use join entities for large or queryable relationships:
await conn.transact([
  {":role-assignment/user": [":user/id", "u-1"],
   ":role-assignment/role": admin},
  {":role-assignment/user": [":user/id", "u-1"],
   ":role-assignment/role": editor}
]);

2.3 Reference Integrity

Because Datalevin is fact-oriented and flexible, it does not enforce traditional foreign-key constraints by default. If you delete an entity by retracting only its own attributes, references to its id can remain.

To ensure a clean removal of an entity and its associated facts, use [:db/retractEntity <eid>]. It retracts facts where the entity is the subject, retracts facts where the entity is the value of declared :db.type/ref attributes, and recursively retracts component children.

3. Attribute Properties: Search and Ownership

Datalevin allows you to attach properties to attributes that change how the database indexes, searches, or interprets relationships. These are different from value types: :db/valueType says what kind of value an attribute stores, while properties such as :db/fulltext, :db/embedding, and :db/isComponent add behavior around that value.

3.1 Full-Text Search (:db/fulltext)

Setting :db/fulltext true tells Datalevin to maintain a specialized full-text index for that attribute, indexing values as text. This enables the (fulltext ...) predicate in Datalog (see Chapter 16).

3.2 Embedding Search (:db/embedding)

Setting :db/embedding true on a :db.type/string attribute tells Datalevin to compute text embeddings and maintain an embedding similarity index. Query it with embedding-neighbors using query text, not a vector (see Chapter 17).

3.3 Component Attributes (:db/isComponent)

When a reference attribute is marked as :db/isComponent true, it signals a single-owner parent-child relationship.

Components are not uniqueness constraints

:db/isComponent is not a uniqueness constraint. Datalevin will allow two parents to point at the same component child, but that database is not well-formed for component semantics.

If a parent is retracted with :db/retractEntity, or the component attribute is retracted with :db.fn/retractAttribute, Datalevin retracts the component child as owned data. Retracting that child also retracts declared reference datoms that point to it, so another parent that pointed at the same child loses its reference too. A value-specific [:db/retract parent component-attr child] only removes that one reference and does not cascade.

This is ideal for modeling owned data, like line items in an invoice or segments of a document. Use ordinary reference attributes for data that can be shared by several parents.

4. Specialized Value Types

Two specialized value types have secondary indexing behavior: vector values and indexed documents. They are declared with :db/valueType, just like strings, longs, refs, and tuples. They appear here only to clarify where they fit in schema design.

4.1 Vector Values (:db.type/vec)

Use :db.type/vec when your application supplies vectors directly. Query these attributes with vec-neighbors. Vector dimensions and metric settings belong in store options such as :vector-opts or :vector-domains, not in each individual datom.

4.2 Indexed Documents (:db.type/idoc)

Use :db.type/idoc for nested maps that should be stored as one value but queried by path with idoc-match. This is useful for flexible metadata, JSON import, and Markdown-derived structures.

5. Best Practices: Designing for Evolution

Datalevin's flexibility makes schema maintenance an ongoing design task. These practices keep the schema understandable as the model evolves.

5.1 Namespace Everything

Always use namespaces for your attributes. A common pattern is [domain]/[property], such as :account/balance or :sensor/reading. This prevents collisions if you later integrate third-party data or modularize your application.

5.2 Prefer Flat Entities

While :db/isComponent is useful for true ownership, do not over-nest your data. Datalevin excels at flat, normalized facts. The query engine is designed to join flat facts efficiently, so you do not need to "pre-join" data into complex documents as you might in a document store like MongoDB.

5.3 Modeling Enums with :db/ident

For attributes that have a fixed set of values, such as status or type, prefer enum entities with :db/ident over raw keyword or string values. Section 1.3 shows the schema and transaction mechanics. The modeling payoff is:

  1. Descriptive facts: You can attach display names, descriptions, or translations directly to the enum entity, as shown in Section 1.3.
  2. Reference semantics: If you use a :db.type/ref for the status attribute, Datalog joins and pulls treat the status as an entity reference rather than an arbitrary string.
  3. Discovery: You can query for all possible statuses using Datalog.

5.4 Evolve Schema Explicitly

Datalevin can create schema entries when new attributes appear in transaction data, but schema is still operational state. Initial schema is passed when opening a connection; later changes go through update-schema on the open connection. Schema changes are not transacted as ordinary datoms.

Keep intended schema in version control

Because a schema definition is EDN data, projects in any Datalevin language binding can keep the intended schema in a checked-in EDN file and load it at startup before creating a connection or calling update-schema. That keeps schema changes reviewable with code.

Treat that file as source for desired schema; the database still stores the effective schema, and live changes still go through update-schema.

Use d/schema to inspect the current effective schema before and after a change:

(d/schema conn)
Map<?, ?> effectiveSchema = conn.schema();
effective_schema = conn.schema()
const effectiveSchema = await conn.schema();

The returned map includes the schema you supplied, built-in attributes such as :db/ident, and Datalevin's internal attribute ids. This is useful for checking what the database actually knows about an attribute, debugging unexpected value encoding or lookup-ref behavior, and verifying that a schema migration did what you expected.

Do not confuse d/schema with the Java helper Datalevin.schema() used in multi-language examples. d/schema reads schema from an open database connection; Datalevin.schema() is a host-language builder for constructing a schema map before passing it to Datalevin.

Use schema evolution for three common cases:

  1. Add or refine schema properties for an attribute your application now wants to query, sort, search, or validate more precisely.
  2. Rename an attribute when the model name has changed but the existing facts should remain. Datalevin keeps the same internal attribute id, so existing datoms become readable under the new attribute name.
  3. Delete schema metadata for an attribute only after all datoms using that attribute have been removed. Datalevin rejects deletion while facts still exist for the attribute.
;; Add or refine schema for an open connection.
(d/update-schema conn
                 {:user/last-login {:db/valueType :db.type/instant}})

;; Rename an attribute. Existing facts are read as :user/contact-email.
(d/update-schema conn nil nil {:user/email :user/contact-email})

;; Delete schema metadata only after no facts remain for the attribute.
(d/update-schema conn nil #{:user/temporary-note})
// Add or refine schema for an open connection.
Schema update = Datalevin.schema()
    .attr(":user/last-login", Schema.attribute()
        .valueType(Schema.ValueType.INSTANT));

conn.updateSchema(update);

// Rename an attribute. Existing facts are read as :user/contact-email.
conn.updateSchema((Schema) null, null,
    Map.of(":user/email", ":user/contact-email"));

// Delete schema metadata only after no facts remain for the attribute.
conn.updateSchema((Schema) null, List.of(":user/temporary-note"), null);
# Add or refine schema for an open connection.
conn.update_schema({":user/last-login":
                    {":db/valueType": ":db.type/instant"}})

# Rename an attribute. Existing facts are read as :user/contact-email.
conn.update_schema(None,
                   rename_map={":user/email": ":user/contact-email"})

# Delete schema metadata only after no facts remain for the attribute.
conn.update_schema(None, del_attrs=[":user/temporary-note"])
// Add or refine schema for an open connection.
await conn.updateSchema({
  ":user/last-login": {":db/valueType": ":db.type/instant"}
});

// Rename an attribute. Existing facts are read as :user/contact-email.
await conn.updateSchema(null, {
  renameMap: {":user/email": ":user/contact-email"}
});

// Delete schema metadata only after no facts remain for the attribute.
await conn.updateSchema(null, {delAttrs: [":user/temporary-note"]});

One especially useful evolution path is moving a prototype attribute from untyped EDN storage to a typed encoding. When you use update-schema to add :db/valueType to an existing attribute that was previously untyped, Datalevin migrates the stored values as part of the schema update:

  1. It reads the existing EDN binary values for the attribute.
  2. It attempts to decode each value into the new declared type.
  3. It rewrites the index entries to use the typed encoding, enabling efficient typed comparisons and range scans.

If any value cannot be coerced, the schema update fails before rewriting the attribute. Datalevin raises Cannot migrate attribute values to new type with the attribute, target type, and the entity/value pairs that failed conversion. Fix or retract those values, then run update-schema again.

This path supports the common workflow of starting flexible during prototyping and tightening the schema once access patterns are known. It applies to the untyped-to-typed case; changing one declared type to another declared type is a different, incompatible migration once data exists.

Not every property can be changed freely once data exists. Datalevin validates schema mutations against stored datoms:

For incompatible changes, treat the change as a data migration: introduce the new attribute, backfill it from the old one, move readers and writers, retract the old facts, then delete the old schema metadata. This is safer than changing a populated attribute in place.

5.5 Use Schema Validation and Coercion Deliberately

Datalevin schema also participates in transaction preparation. Declared :db/valueType properties tell Datalevin how to encode values, and store options decide how strictly the transaction input is checked before those values are written.

Two store options are especially important:

Datalevin does not store nil values. In JavaScript and Python client code, this also means application-level null or None must not be used as a stored attribute value. A missing value is represented by the absence of a datom, not by a stored null marker. If a value becomes unknown or inapplicable, retract the existing datom or omit the attribute in new transaction data.

Derived tuple attributes are the special case described in Section 1.2: Datalevin may maintain nil placeholders inside a derived tuple value when component attributes are missing. Application transaction data still cannot use nil as the value of an attribute.

With :validate-data? true, a transaction that writes "42" to :user/age, a string UUID to a :db.type/uuid attribute, or a timestamp string to a :db.type/instant attribute is rejected unless the value already has the declared runtime type. This is useful at system boundaries where you want malformed input to fail early.

With the default :validate-data? false, Datalevin still uses the declared type to canonicalize values where possible before encoding ordinary attribute values. For example, values may be converted to strings, numeric values may be narrowed to long, float, or double, strings may be parsed as UUIDs, integers may be converted to instants, keywords and symbols may be normalized, and tuple values are stored as vectors. This is convenient for trusted inputs, but it is not a substitute for application validation when user input quality matters. In particular, use correctly typed values for identities and lookup-facing attributes; Datalevin may need those values before general value correction.

Use :db.attr/preds for checks that depend only on one attribute value. An attribute predicate may be a qualified symbol, a predicate UDF descriptor, a registered UDF keyword id, or a non-empty sequence of those. Each predicate receives the normalized attribute value and must return exactly true.

The descriptor form is portable across embedded languages: register the predicate in the runtime, then reference the descriptor from schema:

(def routing-number
  {:udf/lang :clojure
   :udf/kind :predicate
   :udf/id   :payments/routing-number?})

(udf/register! registry routing-number
  #(boolean (and (string? %) (re-matches #"\d{9}" %))))

(def schema
  {:bank/routing-number {:db/valueType  :db.type/string
                         :db.attr/preds routing-number}})
UdfDescriptor routingNumber =
    Datalevin.predicateUdf(":payments/routing-number?");

registry.register(routingNumber, args -> {
    Object value = args.get(0);
    return value instanceof String
        && ((String) value).matches("\\d{9}");
});

Schema schema = Datalevin.schema()
    .attr(":bank/routing-number",
          Map.of(":db/valueType", ":db.type/string",
                 ":db.attr/preds", routingNumber));
routing_number = udf_descriptor(
    ":payments/routing-number?",
    kind=":predicate",
    lang=":python",
)


def routing_number_pred(value):
    return isinstance(value, str) and value.isdigit() and len(value) == 9


registry.register(routing_number, routing_number_pred)

schema = {
    ":bank/routing-number": {
        ":db/valueType": ":db.type/string",
        ":db.attr/preds": routing_number,
    },
}
const routingNumber = udfDescriptor(":payments/routing-number?", {
  kind: ":predicate",
  lang: ":javascript"
});

await registry.register(routingNumber, (value) =>
  typeof value === "string" && /^\d{9}$/.test(value));

const schema = {
  ":bank/routing-number": {
    ":db/valueType": ":db.type/string",
    ":db.attr/preds": routingNumber
  }
};

An attribute predicate runs when that attribute is present. It is not an entity shape rule: it does not decide that every bank account must have both a routing number and an account number, or that every Venmo account must have a handle. Use :db/ensure for entity invariants over the would-be transaction result.

A :db/ensure predicate receives the would-be database after applying the transaction followed by its resolved arguments, and should return truthy on success. It runs after transaction functions, tempid resolution, upserts, map expansion, retractions, and other transaction data have produced the would-be final datoms. Chapter 6 covers the full transaction mechanics; from a schema design point of view, the important distinction is simple: :db.attr/preds checks one value, while :db/ensure checks the would-be entity or transaction result.

The transaction shape is portable. Assume bank-account/bankAccount/ bank_account is a registered predicate descriptor that checks the would-be entity:

(d/transact! conn
  [{:db/id               "acct"
    :account/identity    "acct-1"
    :account/type        :account.type/bank
    :bank/routing-number "021000021"
    :bank/account-number "123456789"}
   [:db/ensure bank-account "acct"]])
conn.transact(List.of(
    Tx.entity("acct")
        .put(":account/identity", "acct-1")
        .put(":account/type", Datalevin.kw(":account.type/bank"))
        .put(":bank/routing-number", "021000021")
        .put(":bank/account-number", "123456789")
        .build(),
    List.of(Datalevin.kw(":db/ensure"), bankAccount.build(), "acct")));
conn.transact([
    {":db/id": "acct",
     ":account/identity": "acct-1",
     ":account/type": keyword(":account.type/bank"),
     ":bank/routing-number": "021000021",
     ":bank/account-number": "123456789"},
    [keyword(":db/ensure"), bank_account, "acct"],
])
const bank = await keyword(":account.type/bank");
const ensure = await keyword(":db/ensure");

await conn.transact([
  {":db/id": "acct",
   ":account/identity": "acct-1",
   ":account/type": bank,
   ":bank/routing-number": "021000021",
   ":bank/account-number": "123456789"},
  [ensure, bankAccount, "acct"]
]);

If a failing test wrote nil, null, or None for :bank/account-number, Datalevin's nil-value rejection would fail first. To demonstrate that the entity-shape invariant rejects a missing required fact, omit the datom and let :db/ensure inspect the would-be result before commit.

Summary: The Schema Checklist

When adding a new attribute to your Datalevin database, ask yourself:

  1. What is the type? Use a specific type (:db.type/long, :db.type/string, etc.) for performance and range queries.
  2. Is it a reference? Use :db.type/ref to enable joins and graph traversals.
  3. Is it a unique identity? Use :db.unique/identity for natural keys (like emails) that you will use to lookup or upsert entities.
  4. Is it a system-wide constant? Use :db/ident to give a unique, globally namespaced keyword to an entity, well suited for enums and static system data.
  5. Is it many-valued? Use :db.cardinality/many only for small, bounded sets of values. Before adding it, ask whether the ref belongs on the many side as :db.cardinality/one; use join entities when the relationship needs its own facts.
  6. Does it need keyword search? Use :db/fulltext for attributes you want to search as text.
  7. Does it need semantic text search? Use :db/embedding for string attributes Datalevin should embed.
  8. Do you already have vectors? Use :db.type/vec and configure vector domains.
  9. Is it flexible nested data? Use :db.type/idoc for path-indexed documents.
  10. Is it a component? Use :db/isComponent for single-owner nested entities.
  11. How will it evolve? Use update-schema for explicit schema changes, and plan migrations for incompatible changes on populated attributes.
  12. How strict should writes be? Use :validate-data? and :closed-schema? when transaction input should be checked against the declared schema.
  13. Where do invariants belong? Use :db.attr/preds for value-local checks, and :db/ensure for invariants over the would-be transaction result.
  14. Can it be absent? Represent absence by omitting or retracting datoms, not by storing nil, null, or sentinel strings.

Applied consistently, these choices keep the schema flexible during development and stable enough for complex applications.

References

[1] 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.

[2] 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.

No examples for this chapter yet.