DatalevinDatalevin
Part I - Foundations: A Fact-First Database

Chapter 2: Your First Datalevin Session

To get started, this chapter gives you one small interactive session with embedded Datalevin: open a database, transact facts, query them, close the connection, and reopen the same database. The remaining chapters build on this first session.

Before You Start

The printed book shows executable code examples in Clojure only. The web edition also includes Java, Python, and JavaScript examples.

Appendix A has the full installation instructions for language bindings, the standalone dtlv executable, Docker, Babashka, and MCP.

Add the Datalevin package for the language you want to use:

;; deps.edn
{:deps {org.datalevin/datalevin-embedded {:mvn/version "1.0.0"}}}
<!-- Maven pom.xml -->
<dependency>
  <groupId>org.datalevin</groupId>
  <artifactId>datalevin-java</artifactId>
  <version>1.0.0</version>
</dependency>

// Gradle Kotlin DSL
repositories {
    mavenCentral()
}

dependencies {
    implementation("org.datalevin:datalevin-java:1.0.0")
}
python -m pip install datalevin
npm install datalevin-node

Datalevin uses native storage code through the JVM and requires Java 21 or newer. When starting the JVM directly, pass the required JVM options; language packages that manage the JVM for you handle these options automatically.

;; deps.edn
{:aliases
 {:datalevin/jvm
  {:jvm-opts ["--add-opens=java.base/java.nio=ALL-UNNAMED"
              "--add-opens=java.base/sun.nio.ch=ALL-UNNAMED"
              "--enable-native-access=ALL-UNNAMED"]}}}
java --add-opens=java.base/java.nio=ALL-UNNAMED \
     --add-opens=java.base/sun.nio.ch=ALL-UNNAMED \
     --enable-native-access=ALL-UNNAMED \
     -cp target/classes:your-dependencies.jar your.main.Class
# No extra JVM flags are normally needed.
python your_program.py
// No extra JVM flags are normally needed.
node your-program.mjs

Start a REPL with clojure -M:datalevin/jvm.

Troubleshooting

If the first connection-opening call fails, see Appendix A, Section 14 Troubleshooting, before changing the example code.

1. Open a Database

Import the public API namespace datalevin.core, define a directory path on disk, and define a small schema:

(require '[datalevin.core :as d])

(def db-path "/tmp/datalevin-guide-getting-started")

(def schema
  {:user/name {:db/valueType :db.type/string
               :db/unique    :db.unique/identity}
   :user/age  {:db/valueType :db.type/long}
   :user/city {:db/valueType :db.type/string}})
import datalevin.*;

String dbPath = "/tmp/datalevin-guide-getting-started";

Schema schema = Datalevin.schema()
    .attr(":user/name",
          Schema.attribute()
              .valueType(Schema.ValueType.STRING)
              .unique(Schema.Unique.IDENTITY))
    .attr(":user/age",
          Schema.attribute().valueType(Schema.ValueType.LONG))
    .attr(":user/city",
          Schema.attribute().valueType(Schema.ValueType.STRING));
from datalevin import connect

db_path = "/tmp/datalevin-guide-getting-started"

schema = {
    ":user/name": {":db/valueType": ":db.type/string",
                   ":db/unique": ":db.unique/identity"},
    ":user/age": {":db/valueType": ":db.type/long"},
    ":user/city": {":db/valueType": ":db.type/string"}}
import { connect } from "datalevin-node";

const dbPath = "/tmp/datalevin-guide-getting-started";

const schema = {
  ":user/name": { ":db/valueType": ":db.type/string",
                  ":db/unique": ":db.unique/identity" },
  ":user/age": { ":db/valueType": ":db.type/long" },
  ":user/city": { ":db/valueType": ":db.type/string" }
};

In all Clojure snippets, datalevin.core is given the alias d for brevity, so later calls can use d/q rather than datalevin.core/q, for example.

A Datalevin schema is a map from attribute keywords to attribute properties. Here, :user/name is an attribute. Its property map says that values should be strings and that the attribute is a unique identity, meaning it can identify an entity. If a later transaction uses the same user name, Datalevin can resolve it to the same entity instead of creating a duplicate entity.

Open a database connection at the db-path directory path defined above:

(def conn (d/get-conn db-path schema))
Connection conn = Datalevin.getConn(dbPath, schema);
conn = connect(db_path, schema=schema)
const conn = await connect(dbPath, { schema });

The path names the local database directory. The directory does not need to exist: Datalevin creates it, so the parent directory must be writable. If the database already exists, Datalevin opens it and uses the supplied schema to ensure the attributes are available.

2. Transact Facts

Transact two user entities to the database:

(d/transact! conn
  [{:user/name "Alice"
    :user/age  30
    :user/city "Paris"}
   {:user/name "Bob"
    :user/age  25
    :user/city "Berlin"}])
conn.transact(Datalevin.tx()
    .entity(Tx.entity()
        .put(":user/name", "Alice")
        .put(":user/age", 30L)
        .put(":user/city", "Paris"))
    .entity(Tx.entity()
        .put(":user/name", "Bob")
        .put(":user/age", 25L)
        .put(":user/city", "Berlin")));
conn.transact([
    {":user/name": "Alice", ":user/age": 30, ":user/city": "Paris"},
    {":user/name": "Bob", ":user/age": 25, ":user/city": "Berlin"}])
await conn.transact([
  { ":user/name": "Alice", ":user/age": 30, ":user/city": "Paris" },
  { ":user/name": "Bob", ":user/age": 25, ":user/city": "Berlin" }
]);

In the EDN notation, the transaction data is a vector of two maps, and each map describes facts about one user entity. Datalevin turns those maps into a list of datoms: entity-attribute-value facts. Logically, this transaction creates six datoms:

[10001 :user/name "Alice"]
[10001 :user/age  30]
[10001 :user/city "Paris"]

[10002 :user/name "Bob"]
[10002 :user/age  25]
[10002 :user/city "Berlin"]

In Datalevin, entity ids are 64-bit integers automatically assigned by the system. The exact numeric entity ids in the example above are illustrative only.

After the transaction data is converted into datoms, the datoms are encoded into binary form and inserted into the underlying key-value storage.

A successful transaction call returns a transaction report. In a REPL you may see keys such as :db-before, :db-after, :tx-data, and :tempids in the report; a transaction that introduces previously unknown attributes may also include :new-attributes. Some application code may want to inspect the report to see which datoms were transacted, which ids were generated, or other details worth auditing.

3. Query the Database

Read the current database state from the connection and query it:

(d/q '[:find ?name
       :where
       [?user-eid :user/name ?name]
       [?user-eid :user/age ?age]
       [(> ?age 28)]]
     (d/db conn))
;; => #{["Alice"]}
Object result = conn.query(
    "[:find ?name " +
    " :where " +
    " [?user-eid :user/name ?name] " +
    " [?user-eid :user/age ?age] " +
    " [(> ?age 28)]]");
// => [["Alice"]]
result = conn.query("""
    [:find ?name
     :where
     [?user-eid :user/name ?name]
     [?user-eid :user/age ?age]
     [(> ?age 28)]]""")
# => [["Alice"]]
const result = await conn.query(
  `[:find ?name
    :where
    [?user-eid :user/name ?name]
    [?user-eid :user/age ?age]
    [(> ?age 28)]]`);
// => [["Alice"]]

For now, read the query as: find the names of users whose age is greater than 28. Chapter 8 explains Datalog in detail. The important first point is that a query is also a piece of data: a vector of keywords and nested vectors.

The :find keyword names what the query should return. Here it returns ?name. In Clojure, the result is a set of tuples, so the one matching name prints as #{["Alice"]}. Java, Python, and JavaScript return the same relation as language-native list or array values. Chapter 8 covers the other result shapes.

The :where keyword is followed by a list of vectors, each of which is called a where clause. Two kinds of where clauses are shown here.

[?user-eid :user/name ?name] is a triple pattern, which describes facts that must be true for the query to return non-empty results. The entity position of the match pattern is a ?user-eid variable, the attribute is given, and the value position is a ?name variable. When a datom in the database matches this triple pattern, its entity id and value will be bound to these two variables, respectively.

[(> ?age 28)] is a predicate, a boolean function that must evaluate to true for the query to return non-empty results. > is the function name. This predicate tests whether the value bound to ?age is greater than 28.

Careful readers may notice that the whole query was prefixed by a quote character '. This prevents the query data from being evaluated as Clojure code. The variable symbols in the query are meaningful only inside that query, not for the surrounding code, where they would be considered undefined.

The function call (d/db conn) gives d/q access to the current database state, a DB object, as the read handle over the underlying storage plus cached metadata for that storage state. Get it when you are about to read, use it for that operation, and ask the connection again before making a later decision. A query runs against data, not against the connection object itself. Writes are different: d/transact! works through a connection because a transaction changes the database.

4. Close and Reopen

Close the connection when the process is done with it:

(d/close conn)
conn.close();
conn.close()
await conn.close();

Because the database was persistent on disk at the specified path, the facts survive process restart. Open the same path again and query the data:

(def conn2 (d/get-conn db-path schema))

(d/q '[:find ?city
       :where
       [?user-eid :user/name "Bob"]
       [?user-eid :user/city ?city]]
     (d/db conn2))
;; => #{["Berlin"]}

(d/close conn2)
Connection conn2 = Datalevin.getConn(dbPath, schema);

Object result = conn2.query(
    "[:find ?city " +
    " :where " +
    " [?user-eid :user/name \"Bob\"] " +
    " [?user-eid :user/city ?city]]");
// => [["Berlin"]]

conn2.close();
conn2 = connect(db_path, schema=schema)

result = conn2.query("""
    [:find ?city
     :where
     [?user-eid :user/name "Bob"]
     [?user-eid :user/city ?city]]""")
# => [["Berlin"]]

conn2.close()
const conn2 = await connect(dbPath, { schema });

const result = await conn2.query(
  `[:find ?city
    :where
    [?user-eid :user/name "Bob"]
    [?user-eid :user/city ?city]]`);
// => [["Berlin"]]

await conn2.close();

Next Steps

You have seen the core loop: open a connection, transact facts, query current database state, and close the connection. Chapter 3 explains the mental model behind those operations. Chapter 4 explains the storage layer that makes the operations fast and durable. Chapter 5 explains attributes and entities in depth. Appendix A covers details of Datalevin installation and deployment options.

No examples for this chapter yet.