Chapter 7: Reading Data
Once data is in your database, you need a way to retrieve it in the shape your application actually uses. Datalevin gives you several read APIs, each with a different job:
- Lookup refs let you address an entity by a domain identifier such as an email, SKU, username, or order number.
d/entityreturns a lazy, map-like entity object for interactive or conditional navigation.d/pullfetches one entity as a plain map in a declared shape, including nested references and recursive trees.d/qfinds sets of values and entities using Datalog constraints.
This chapter covers the first three. Chapter 8 focuses on Datalog query syntax, but queries appear briefly here when they help explain lookup refs.
1. Reading by Identity with Lookup Refs
Datalevin stores entities internally by numeric entity id. Application code, however, usually knows a natural key: a user's email, an account number, a URL slug, or an external system id. A lookup ref is the bridge between those two worlds.
Use lookup refs at application boundaries
Prefer lookup refs and unique domain attributes at application boundaries because entity ids are database-local handles; the same logical entity may have different numeric ids in two databases.
A lookup ref is a two-element vector:
[:user/email "alice@example.com"]
List.of(":user/email", "alice@example.com")
[":user/email", "alice@example.com"]
[":user/email", "alice@example.com"]
The first element must be an attribute marked :db/unique; Chapter 11 explains how to choose between uniqueness modes when designing identity attributes. The second element is the value of that unique attribute. Datalevin resolves the vector to the matching entity id when an API expects an entity.
(def schema
{:user/email {:db/valueType :db.type/string
:db/unique :db.unique/identity}
:user/name {:db/valueType :db.type/string}
:user/friends {:db/valueType :db.type/ref
:db/cardinality :db.cardinality/many}
:order/id {:db/valueType :db.type/string
:db/unique :db.unique/identity}
:order/customer {:db/valueType :db.type/ref}})
(d/transact! conn
[{:user/email "alice@example.com"
:user/name "Alice"}
{:user/email "bob@example.com"
:user/name "Bob"
:user/friends [[:user/email "alice@example.com"]]}
{:order/id "o-1001"
:order/customer [:user/email "alice@example.com"]}])
(def db (d/db conn))
Schema schema = Datalevin.schema()
.attr(":user/email",
Schema.attribute()
.valueType(Schema.ValueType.STRING)
.unique(Schema.Unique.IDENTITY))
.attr(":user/name",
Schema.attribute().valueType(Schema.ValueType.STRING))
.attr(":user/friends",
Schema.attribute()
.valueType(Schema.ValueType.REF)
.cardinality(Schema.Cardinality.MANY))
.attr(":order/id",
Schema.attribute()
.valueType(Schema.ValueType.STRING)
.unique(Schema.Unique.IDENTITY))
.attr(":order/customer",
Schema.attribute().valueType(Schema.ValueType.REF));
Connection conn = Datalevin.getConn("/tmp/reading-demo", schema);
conn.transact(Datalevin.tx()
.entity(Tx.entity()
.put(":user/email", "alice@example.com")
.put(":user/name", "Alice"))
.entity(Tx.entity()
.put(":user/email", "bob@example.com")
.put(":user/name", "Bob")
.put(":user/friends",
List.of(List.of(":user/email", "alice@example.com"))))
.entity(Tx.entity()
.put(":order/id", "o-1001")
.put(":order/customer",
List.of(":user/email", "alice@example.com"))));
schema = {
":user/email": {
":db/valueType": ":db.type/string",
":db/unique": ":db.unique/identity",
},
":user/name": {":db/valueType": ":db.type/string"},
":user/friends": {
":db/valueType": ":db.type/ref",
":db/cardinality": ":db.cardinality/many",
},
":order/id": {
":db/valueType": ":db.type/string",
":db/unique": ":db.unique/identity",
},
":order/customer": {":db/valueType": ":db.type/ref"},
}
conn = connect("/tmp/reading-demo", schema=schema)
conn.transact([
{":user/email": "alice@example.com",
":user/name": "Alice"},
{":user/email": "bob@example.com",
":user/name": "Bob",
":user/friends": [[":user/email", "alice@example.com"]]},
{":order/id": "o-1001",
":order/customer": [":user/email", "alice@example.com"]},
])
const schema = {
":user/email": {
":db/valueType": ":db.type/string",
":db/unique": ":db.unique/identity"
},
":user/name": { ":db/valueType": ":db.type/string" },
":user/friends": {
":db/valueType": ":db.type/ref",
":db/cardinality": ":db.cardinality/many"
},
":order/id": {
":db/valueType": ":db.type/string",
":db/unique": ":db.unique/identity"
},
":order/customer": { ":db/valueType": ":db.type/ref" }
};
const conn = await connect("/tmp/reading-demo", { schema });
await conn.transact([
{ ":user/email": "alice@example.com",
":user/name": "Alice" },
{ ":user/email": "bob@example.com",
":user/name": "Bob",
":user/friends": [[":user/email", "alice@example.com"]] },
{ ":order/id": "o-1001",
":order/customer": [":user/email", "alice@example.com"] }
]);
Lookup refs work in the same places where you would otherwise pass an entity id:
;; Resolve to the numeric entity id when you explicitly need it.
(d/entid db [:user/email "alice@example.com"])
;; => 1
;; Pull by lookup ref.
(d/pull db [:user/name :user/email] [:user/email "alice@example.com"])
;; => {:user/name "Alice", :user/email "alice@example.com"}
;; Navigate by lookup ref.
(def alice (d/entity db [:user/email "alice@example.com"]))
(:user/name alice)
;; => "Alice"
// Resolve to the numeric entity id when you explicitly need it.
Object aliceId = conn.entid(List.of(":user/email", "alice@example.com"));
// Pull by lookup ref.
Map<?, ?> alice =
conn.pull("[:user/name :user/email]",
List.of(":user/email", "alice@example.com"));
// Materialize by lookup ref.
Map<?, ?> aliceEntity =
conn.entityMap(List.of(":user/email", "alice@example.com"));
# Resolve to the numeric entity id when you explicitly need it.
alice_id = conn.entid([":user/email", "alice@example.com"])
# Pull by lookup ref.
alice = conn.pull('[:user/name :user/email]',
[":user/email", "alice@example.com"])
# Navigate by lookup ref.
alice_entity = conn.entity([":user/email", "alice@example.com"])
alice_entity[":user/name"]
// Resolve to the numeric entity id when you explicitly need it.
const aliceId = await conn.entid([":user/email", "alice@example.com"]);
// Pull by lookup ref.
const alice = await conn.pull('[:user/name :user/email]',
[':user/email', 'alice@example.com']);
// Navigate by lookup ref.
const aliceEntity = await conn.entity([":user/email", "alice@example.com"]);
await aliceEntity.get(":user/name");
Lookup refs are also valid inputs to Datalog queries. Here, the :in clause declares the query's inputs: the database $, followed by the ?customer parameter. Chapter 8 covers query inputs in detail. The query engine resolves lookup refs when they occur in positions that require an entity id:
(d/q '[:find ?order-id
:in $ ?customer
:where
[?order :order/customer ?customer]
[?order :order/id ?order-id]]
db
[:user/email "alice@example.com"])
;; => #{["o-1001"]}
Object orderIds =
conn.query("[:find ?order-id " +
" :in $ ?customer " +
" :where [?order :order/customer ?customer] " +
" [?order :order/id ?order-id]]",
List.of(List.of(":user/email", "alice@example.com")));
order_ids = conn.query(
'[:find ?order-id '
':in $ ?customer '
':where [?order :order/customer ?customer] '
' [?order :order/id ?order-id]]',
[":user/email", "alice@example.com"])
const orderIds = await conn.query(
'[:find ?order-id ' +
':in $ ?customer ' +
':where [?order :order/customer ?customer] ' +
' [?order :order/id ?order-id]]',
[':user/email', 'alice@example.com']
);
If a lookup ref does not resolve, d/entid, d/entity, and d/pull return the host language's nil value: nil, null, or None.
2. Entity API: Lazy Map-Like Navigation
d/entity returns a map-like entity object tied to the Datalog Db handle used to create it. Entity navigation starts from a known entity and reads attributes as the program asks for them. In Clojure, Python, and JavaScript, the connection-level entity APIs navigate lazily. Java examples use entityMap when they want ordinary Map values at the connection level. Pass either a lookup ref or a numeric entity id:
(def alice (d/entity db [:user/email "alice@example.com"]))
(:user/name (d/entity db 1))
;; => "Alice"
Map<?, ?> alice =
conn.entityMap(List.of(":user/email", "alice@example.com"));
conn.entityMap(1).get(Datalevin.kw(":user/name"));
alice = conn.entity([":user/email", "alice@example.com"])
conn.entity(1)[":user/name"]
const alice = await conn.entity([":user/email", "alice@example.com"]);
const aliceById = await conn.entity(1);
await aliceById.get(":user/name");
An entity behaves like a map for attribute lookup:
(:db/id alice)
;; => 1
(:user/name alice)
;; => "Alice"
(alice :user/email)
;; => "alice@example.com"
(contains? alice :user/name)
;; => true
alice.get(Datalevin.kw(":db/id"));
alice.get(Datalevin.kw(":user/name"));
alice.get(Datalevin.kw(":user/email"));
alice.containsKey(Datalevin.kw(":user/name"));
alice[":db/id"]
alice.get(":user/name")
alice[":user/email"]
":user/name" in alice
await alice.id();
await alice.get(":user/name");
await alice.get(":user/email");
await alice.has(":user/name");
For lazy entity APIs, reading (:user/name alice) or alice.get(":user/name") fetches and caches that attribute. Reading another attribute may touch storage again. Java's entityMap example below has already materialized the map, but it serves the same conditional-navigation use case:
(when-let [alice (d/entity db [:user/email "alice@example.com"])]
(if (:user/active? alice)
(:user/name alice)
"inactive"))
Map<?, ?> alice = conn.entityMap(List.of(":user/email", "alice@example.com"));
String displayName =
Boolean.TRUE.equals(alice.get(Datalevin.kw(":user/active?")))
? (String) alice.get(Datalevin.kw(":user/name"))
: "inactive";
alice = conn.entity([":user/email", "alice@example.com"])
display_name = (
alice[":user/name"]
if alice is not None and alice.get(":user/active?")
else "inactive")
const alice = await conn.entity([":user/email", "alice@example.com"]);
const displayName =
alice && (await alice.get(":user/active?"))
? await alice.get(":user/name")
: "inactive";
Performance: lazy entity reads
Entity navigation does not go through a query-planning path: a forward attribute read is an indexed pattern lookup for that entity and attribute, and a reverse reference read is an indexed lookup for entities that point to the current entity.
The performance tradeoff is the laziness itself. A single lookup is predictable, but walking many entities or many references can become many small reads whose count depends on application control flow. For bulk reads and API responses, prefer d/q with pull, d/pull, or d/pull-many so the read shape is explicit.
Use d/touch, .touch(), or the Java entityMap convenience when you intentionally want to realize all attributes of an entity for debugging, logging, or exploratory REPL work:
(pr-str (d/entity db [:user/email "alice@example.com"]))
;; => "{:db/id 1}"
(d/touch (d/entity db [:user/email "alice@example.com"]))
;; prints like:
;; {:db/id 1,
;; :user/name "Alice",
;; :user/email "alice@example.com"}
Map<?, ?> alice =
conn.entityMap(List.of(":user/email", "alice@example.com"));
alice = conn.entity([":user/email", "alice@example.com"])
alice.touch()
const alice = await conn.entity([":user/email", "alice@example.com"]);
await alice.touch();
In Clojure, d/touch realizes the attributes, but it does not turn the entity into a plain persistent map. The value is still an entity object. That distinction matters with functions such as merge that construct maps by conjoining entries:
(def alice-ent (d/touch (d/entity db [:user/email "alice@example.com"])))
;; Do this when ordinary map operations should own the value:
(merge (into {} alice-ent)
{:api/source "profile"})
2.1 Entity References
When an entity attribute is declared as :db.type/ref, lazy entity navigation returns entity objects for the referenced entities.
(def bob (d/entity db [:user/email "bob@example.com"]))
(:user/friends bob)
;; => a set containing Alice's entity
(map :user/name (:user/friends bob))
;; => ("Alice")
Map<?, ?> bob =
conn.pull("[:user/name {:user/friends [:user/name]}]",
List.of(":user/email", "bob@example.com"));
bob = conn.entity([":user/email", "bob@example.com"])
friends = bob[":user/friends"]
[friend[":user/name"] for friend in friends]
const bob = await conn.entity([":user/email", "bob@example.com"]);
const friends = await bob.get(":user/friends");
const names = await Promise.all(
Array.from(friends).map((friend) => friend.get(":user/name"))
);
For lazy entity APIs, a cardinality-one reference returns one entity and a cardinality-many reference returns a set of entities. Those referenced entities are lazy too, so this kind of navigation can be very convenient in a REPL or in small conditional branches. Java examples here use pull for the same result shape because connection-level Java reads commonly return materialized maps.
2.2 Reverse Navigation on Entities
Entity navigation also supports reverse attributes:
(def alice (d/entity db [:user/email "alice@example.com"]))
(map :order/id (:order/_customer alice))
;; => ("o-1001")
Map<?, ?> alice =
conn.pull("[:user/name {:order/_customer [:order/id]}]",
List.of(":user/email", "alice@example.com"));
alice = conn.entity([":user/email", "alice@example.com"])
orders = alice[":order/_customer"]
[order[":order/id"] for order in orders]
const alice = await conn.entity([":user/email", "alice@example.com"]);
const orders = await alice.get(":order/_customer");
const orderIds = await Promise.all(
Array.from(orders).map((order) => order.get(":order/id"))
);
Reverse attributes use the same spelling everywhere they appear:
:order/customerbecomes:order/_customer:friendbecomes:_friend
Reverse attributes return the entities that point to the current entity through that reference. For non-component references the result is a set. Component references can return a single owner because a component has at most one owner in a well-formed model.
Datalevin does not enforce that ownership invariant when writing refs. If several parents point to the same component child, reverse component navigation returns one owner and the model should be treated as malformed. Use an ordinary reference attribute for shared targets.
2.3 DB Handles and Entity Objects
Datalevin's db is a read handle used by read APIs, not durable application state. Treat it as an operation-local handle. In Clojure, call (d/db conn) at the read site. Connection-level methods in the other bindings read through the connection for you.
Use DB-taking forms when you are inspecting a transaction-report boundary, such as :db-before or :db-after from a simulated transaction report: d/pull in Clojure, Datalevin.pull(db, ...) or DatabaseValue.pull(...) in Java, and db.pull(...) on Python or JavaScript Database objects.
Do not cache entity objects as live state
An entity object is tied to the read handle used to create it. Do not save a db handle or an entity object and expect it to follow later transactions. A materialized entity map is just a copy and will not update either. If current data matters after a write, read again from the connection.
The following example shows the boundary:
(def alice-v1 (d/entity (d/db conn) [:user/email "alice@example.com"]))
(d/transact! conn
[[:db/add [:user/email "alice@example.com"] :user/name "Alice A."]])
(:user/name alice-v1)
;; => "Alice"
(:user/name (d/entity (d/db conn) [:user/email "alice@example.com"]))
;; => "Alice A."
Map<?, ?> aliceV1 =
conn.entityMap(List.of(":user/email", "alice@example.com"));
conn.transact(Datalevin.tx()
.entity(Tx.entity()
.put(":user/email", "alice@example.com")
.put(":user/name", "Alice A.")));
aliceV1.get(Datalevin.kw(":user/name"));
// => "Alice"
conn.entityMap(List.of(":user/email", "alice@example.com"))
.get(Datalevin.kw(":user/name"));
// => "Alice A."
alice_v1 = conn.entity([":user/email", "alice@example.com"])
conn.transact([
{":user/email": "alice@example.com",
":user/name": "Alice A."}])
alice_v1[":user/name"]
# => "Alice"
conn.entity([":user/email", "alice@example.com"])[":user/name"]
# => "Alice A."
const aliceV1 = await conn.entity([":user/email", "alice@example.com"]);
await conn.transact([
{ ":user/email": "alice@example.com",
":user/name": "Alice A." }
]);
await aliceV1.get(":user/name");
// => "Alice"
const aliceV2 = await conn.entity([":user/email", "alice@example.com"]);
await aliceV2.get(":user/name");
// => "Alice A."
2.4 Using d/entity in Transaction Functions
Transaction functions often use entity navigation for read-side checks before returning transaction data:
(defn rename-user [db email new-name]
(if-some [ent (d/entity db [:user/email email])]
[[:db/add (:db/id ent) :user/name new-name]]
(throw (ex-info "No user with email" {:email email}))))
UdfRegistry registry = Datalevin.udfRegistry()
.txFn(":user/rename", args -> {
Object db = args.get(0);
String email = (String) args.get(1);
String newName = (String) args.get(2);
Object eid = Datalevin.entid(db, List.of(":user/email", email));
if (eid == null) {
throw new IllegalArgumentException("No user with email: " + email);
}
return List.of(Tx.add(eid, ":user/name", newName));
});
from datalevin import Database
@registry.tx_udf(":user/rename")
def rename_user(raw_db, email, new_name):
db = Database(raw_db)
ent = db.entity([":user/email", email])
if ent is None:
raise ValueError(f"No user with email: {email}")
return [[":db/add", ent.id, ":user/name", new_name]]
await registry.txUdf(":user/rename", (_db, email, newName) => {
return [
[":db/add", [":user/email", email], ":user/name", newName]
];
});
The transaction function receives a Db handle at the point where the function is expanded. Use that handle when the function must inspect current data before returning transaction data. Java uses Datalevin.entid here because only the id is needed. Python wraps the raw DB argument in Database before using entity helpers. JavaScript returns a lookup ref directly because this transaction data does not need to inspect the entity.
Chapter 6 covers transaction functions in more detail.
2.5 Transactable Entities
In Clojure, Datalevin Entity objects are not plain maps, but they support a small staging interface for writes. assoc, update, d/add, d/retract, and dissoc return a new Entity value that carries pending changes. The original entity and the Db handle it came from are not modified. The staged changes become transaction data only when the staged entity is passed to d/transact!. This staging interface is Clojure-only; in other bindings, express writes as ordinary transaction maps or datom vectors.
(def alice (d/entity (d/db conn) [:user/email "alice@example.com"]))
(def staged-alice
(-> alice
(assoc :user/name "Alice A.")
(d/add :user/friends [:user/email "bob@example.com"])))
(:user/name staged-alice)
;; => "Alice A."
(:user/name alice)
;; => "Alice"
At this point, only staged-alice sees the pending name change. The database does not change until the staged entity is transacted:
(d/transact! conn [staged-alice])
(d/pull (d/db conn)
[:user/name {:user/friends [:user/name]}]
[:user/email "alice@example.com"])
;; => {:user/name "Alice A.",
;; :user/friends [{:user/name "Bob"}]}
Use d/add when adding a value to a cardinality-many attribute. Use d/retract with a value when removing one value, or dissoc/d/retract without a value when retracting an entire attribute:
(let [db (d/db conn)
alice2 (d/entity db [:user/email "alice@example.com"])
bob (d/entity db [:user/email "bob@example.com"])]
(d/transact! conn
[(d/retract alice2 :user/friends bob)]))
This feature is useful at the REPL and in small update flows because the code can look like ordinary map transformation while still producing normal transaction data. For larger workflows or validation-heavy updates, explicit transaction maps and datom vectors are usually easier to audit and test.
3. Pull: Fetching a Declared Shape
d/pull is the easiest way to fetch a plain data structure for one entity. You provide a Db handle, a pull pattern, and an entity id or lookup ref:
(d/pull db pattern eid-or-lookup-ref)
Map<?, ?> result = conn.pull(pattern, eidOrLookupRef);
result = conn.pull(pattern, eid_or_lookup_ref)
const result = await conn.pull(pattern, eidOrLookupRef);
The pattern is a vector describing which attributes to retrieve.
(d/pull db [:user/name :user/email]
[:user/email "alice@example.com"])
;; => {:user/name "Alice", :user/email "alice@example.com"}
Map<?, ?> alice =
conn.pull("[:user/name :user/email]",
List.of(":user/email", "alice@example.com"));
alice = conn.pull('[:user/name :user/email]',
[":user/email", "alice@example.com"])
const alice = await conn.pull('[:user/name :user/email]',
[':user/email', 'alice@example.com']);
If you already have the numeric entity id, pass it directly to d/pull. You do not need a Datalog query just to retrieve one known entity:
(d/pull db [:user/name :user/email] 1)
;; => {:user/name "Alice", :user/email "alice@example.com"}
Map<?, ?> alice = conn.pull("[:user/name :user/email]", 1);
alice = conn.pull('[:user/name :user/email]', 1)
const alice = await conn.pull('[:user/name :user/email]', 1);
A numeric id is treated as an id, not as an existence check. d/entid returns numeric ids unchanged; use a lookup ref, a query, or a specific attribute read when you need to verify that an entity currently exists.
Use [*] to fetch all forward attributes of the entity:
(d/pull db ['*] [:user/email "alice@example.com"])
;; => {:db/id 1,
;; :user/name "Alice",
;; :user/email "alice@example.com"}
Map<?, ?> alice =
conn.pull("[*]", List.of(":user/email", "alice@example.com"));
alice = conn.pull('[*]', [":user/email", "alice@example.com"])
const alice = await conn.pull('[*]',
[':user/email', 'alice@example.com']);
Wildcard pull is convenient for exploration, but production APIs should usually name the attributes they return. Explicit patterns keep responses stable as the schema grows.
3.1 Nested Pull
Pull patterns can follow reference attributes. If :order/customer has :db/valueType :db.type/ref, a nested map in the pattern tells Datalevin how to shape the referenced entity:
(d/pull db
[:order/id {:order/customer [:user/name :user/email]}]
[:order/id "o-1001"])
Map<?, ?> order =
conn.pull("[:order/id {:order/customer [:user/name :user/email]}]",
List.of(":order/id", "o-1001"));
order = conn.pull('[:order/id {:order/customer [:user/name :user/email]}]',
[":order/id", "o-1001"])
const order =
await conn.pull('[:order/id {:order/customer [:user/name :user/email]}]',
[':order/id', 'o-1001']);
Result:
{:order/id "o-1001",
:order/customer {:user/name "Alice",
:user/email "alice@example.com"}}
Without the nested pattern, an ordinary reference is returned as a small entity reference:
(d/pull db [:order/id :order/customer] [:order/id "o-1001"])
;; => {:order/id "o-1001", :order/customer {:db/id 1}}
Map<?, ?> order =
conn.pull("[:order/id :order/customer]",
List.of(":order/id", "o-1001"));
order = conn.pull('[:order/id :order/customer]',
[":order/id", "o-1001"])
const order = await conn.pull('[:order/id :order/customer]',
[':order/id', 'o-1001']);
That behavior is intentional. Pull only expands what the pattern asks it to expand.
Component references are the important exception: a bare component reference can expand recursively. Section 3.5 shows how to keep component responses explicit.
3.2 Reverse Pull
A reference attribute points forward from one entity to another. For example, an order has :order/customer pointing to a user. Sometimes you want to start from the user and ask, "which orders point here?"
Pull supports this with reverse attributes. For a namespaced attribute like :order/customer, the reverse attribute is :order/_customer. For an unqualified attribute like :friend, the reverse attribute is :_friend.
(d/pull db
[:user/name {:order/_customer [:order/id]}]
[:user/email "alice@example.com"])
;; => {:user/name "Alice",
;; :order/_customer [{:order/id "o-1001"}]}
Map<?, ?> alice =
conn.pull("[:user/name {:order/_customer [:order/id]}]",
List.of(":user/email", "alice@example.com"));
alice = conn.pull('[:user/name {:order/_customer [:order/id]}]',
[":user/email", "alice@example.com"])
const alice =
await conn.pull('[:user/name {:order/_customer [:order/id]}]',
[':user/email', 'alice@example.com']);
As with forward references, a bare reverse attribute returns only small entity reference maps:
(d/pull db
[:user/name :order/_customer]
[:user/email "alice@example.com"])
;; => {:user/name "Alice",
;; :order/_customer [{:db/id 3}]}
Map<?, ?> alice =
conn.pull("[:user/name :order/_customer]",
List.of(":user/email", "alice@example.com"));
alice = conn.pull('[:user/name :order/_customer]',
[":user/email", "alice@example.com"])
const alice =
await conn.pull('[:user/name :order/_customer]',
[':user/email', 'alice@example.com']);
Plain forward pull returns the stored value even when an attribute is untyped. The :db.type/ref declaration matters when the pattern asks Datalevin to traverse: nested pull such as {:order/customer [...]} and reverse pull such as :order/_customer require :order/customer to be declared as a ref. Otherwise Datalevin rejects the pull pattern instead of treating an arbitrary value as an entity id.
3.3 Pulling Many Entities
When you already have several entity ids or lookup refs, d/pull-many applies one pull pattern to all of them and returns the maps in the same order. In embedded code this is mostly a clearer shape with a small benefit: Datalevin prepares the pattern once for the batch. In client/server code it is more important because it avoids one round trip per entity.
(d/pull-many db
[:user/name]
[[:user/email "alice@example.com"]
[:user/email "bob@example.com"]])
;; => [{:user/name "Alice"} {:user/name "Bob"}]
List<?> users =
conn.pullMany("[:user/name]",
List.of(List.of(":user/email", "alice@example.com"),
List.of(":user/email", "bob@example.com")));
users = conn.pull_many(
'[:user/name]',
[[":user/email", "alice@example.com"],
[":user/email", "bob@example.com"]])
const users = await conn.pullMany(
'[:user/name]',
[[':user/email', 'alice@example.com'],
[':user/email', 'bob@example.com']]
);
3.4 Pull Pattern Options
Pull supports attribute options for common response-shaping needs.
3.4.1 Limiting Cardinality-Many Attributes
Pull returns at most 1000 values for a cardinality-many attribute by default. Use :limit to choose a different limit, or nil for no limit. The option is written as an attribute expression:
(d/pull db [[:user/friends :limit 5]]
[:user/email "bob@example.com"])
Map<?, ?> bob =
conn.pull("[[:user/friends :limit 5]]",
List.of(":user/email", "bob@example.com"));
bob = conn.pull('[[:user/friends :limit 5]]',
[":user/email", "bob@example.com"])
const bob = await conn.pull('[[:user/friends :limit 5]]',
[':user/email', 'bob@example.com']);
The same attribute expression can be used as the key in a nested map spec:
(d/pull db
[:user/name {[:user/friends :limit 5] [:user/name]}]
[:user/email "bob@example.com"])
Map<?, ?> bob =
conn.pull("[:user/name {[:user/friends :limit 5] [:user/name]}]",
List.of(":user/email", "bob@example.com"));
bob = conn.pull('[:user/name {[:user/friends :limit 5] [:user/name]}]',
[":user/email", "bob@example.com"])
const bob =
await conn.pull('[:user/name {[:user/friends :limit 5] [:user/name]}]',
[':user/email', 'bob@example.com']);
3.4.2 Default Values
Use :default when the response should include a value even if the entity does not currently have that attribute:
(d/pull db
[:user/name [:user/active? :default true]]
[:user/email "alice@example.com"])
;; => {:user/name "Alice", :user/active? true}
Map<?, ?> alice =
conn.pull("[:user/name [:user/active? :default true]]",
List.of(":user/email", "alice@example.com"));
alice = conn.pull('[:user/name [:user/active? :default true]]',
[":user/email", "alice@example.com"])
const alice =
await conn.pull('[:user/name [:user/active? :default true]]',
[':user/email', 'alice@example.com']);
3.4.3 Attribute Renaming
Use :as when the external response should use a different key:
(d/pull db
[[:user/name :as :name]
[:user/email :as :email]]
[:user/email "alice@example.com"])
;; => {:name "Alice", :email "alice@example.com"}
Map<?, ?> alice =
conn.pull("[[:user/name :as :name] [:user/email :as :email]]",
List.of(":user/email", "alice@example.com"));
alice = conn.pull('[[:user/name :as :name] [:user/email :as :email]]',
[":user/email", "alice@example.com"])
const alice =
await conn.pull('[[:user/name :as :name] [:user/email :as :email]]',
[':user/email', 'alice@example.com']);
Renaming is a presentation choice. It does not change the stored attributes or the schema.
3.4.4 Transforming Pull Values
Use :xform when a value should be transformed before it is placed in the pull result. The transform may be a function value or a resolvable symbol, such as vector or clojure.string/upper-case:
(d/pull db
[[:user/name :as :name :xform 'clojure.string/upper-case]]
[:user/email "alice@example.com"])
;; => {:name "ALICE"}
Map<?, ?> alice =
conn.pull("[[:user/name :as :name :xform clojure.string/upper-case]]",
List.of(":user/email", "alice@example.com"));
alice = conn.pull(
'[[:user/name :as :name :xform clojure.string/upper-case]]',
[":user/email", "alice@example.com"])
const alice =
await conn.pull(
'[[:user/name :as :name :xform clojure.string/upper-case]]',
[':user/email', 'alice@example.com']);
For pull patterns that may be stored or sent across process boundaries, prefer resolvable symbols and make sure the runtime can load them.
If an attribute is missing and no default is supplied, the transform receives nil:
(d/pull db
[[:user/nickname :xform vector]]
[:user/email "alice@example.com"])
;; => {:user/nickname [nil]}
Map<?, ?> alice =
conn.pull("[[:user/nickname :xform vector]]",
List.of(":user/email", "alice@example.com"));
alice = conn.pull('[[:user/nickname :xform vector]]',
[":user/email", "alice@example.com"])
const alice =
await conn.pull('[[:user/nickname :xform vector]]',
[':user/email', 'alice@example.com']);
If :default is present and the attribute is missing, the default wins and is returned as-is:
(d/pull db
[[:user/nickname :default "unknown" :xform vector]]
[:user/email "alice@example.com"])
;; => {:user/nickname "unknown"}
Map<?, ?> alice =
conn.pull("[[:user/nickname :default \"unknown\" :xform vector]]",
List.of(":user/email", "alice@example.com"));
alice = conn.pull('[[:user/nickname :default "unknown" :xform vector]]',
[":user/email", "alice@example.com"])
const alice =
await conn.pull('[[:user/nickname :default "unknown" :xform vector]]',
[':user/email', 'alice@example.com']);
For reference, reverse-reference, and nested pull attributes, :xform receives the pulled value for that attribute. This is useful for small response-shaping steps. For transformations with business rules, error handling, or portability requirements, prefer ordinary application code after the pull.
3.5 Recursive Pull and Component Trees
Nested pull is not limited to one fixed level. When a reference points to the same kind of entity repeatedly, such as a category tree, organization chart, filesystem, or bill of materials, use a recursive pull map. The map value can be ... for unbounded recursion, or a positive number for a bounded depth.
The examples below assume :category/slug is unique and :category/children is a reference attribute.
Use a bounded depth for user-facing APIs unless the graph is naturally small:
(d/pull db
[:category/name {:category/children 2}]
[:category/slug "databases"])
Map<?, ?> tree =
conn.pull("[:category/name {:category/children 2}]",
List.of(":category/slug", "databases"));
tree = conn.pull('[:category/name {:category/children 2}]',
[":category/slug", "databases"])
const tree =
await conn.pull('[:category/name {:category/children 2}]',
[':category/slug', 'databases']);
Use ... when the recursive structure itself is the response, and the data model guarantees a sensible boundary:
(d/pull db
[:category/name {:category/children '...}]
[:category/slug "databases"])
Map<?, ?> tree =
conn.pull("[:category/name {:category/children ...}]",
List.of(":category/slug", "databases"));
tree = conn.pull('[:category/name {:category/children ...}]',
[":category/slug", "databases"])
const tree =
await conn.pull('[:category/name {:category/children ...}]',
[':category/slug', 'databases']);
Recursive pull also works in reverse. If :category/children points from a parent to a child, :category/_children walks from a child back toward its parents:
(d/pull db
[:category/name {:category/_children 3}]
[:category/slug "lmdb"])
Map<?, ?> path =
conn.pull("[:category/name {:category/_children 3}]",
List.of(":category/slug", "lmdb"));
path = conn.pull('[:category/name {:category/_children 3}]',
[":category/slug", "lmdb"])
const path =
await conn.pull('[:category/name {:category/_children 3}]',
[':category/slug', 'lmdb']);
Datalevin tracks entities already seen during a recursive pull. If a cycle is encountered, the repeated entity is represented by {:db/id ...} instead of being expanded forever. That makes recursion safe, but it does not make every recursive response small. Choose explicit depth limits for large graphs.
Component attributes deserve special care. In a well-formed component model, a component child is owned by one parent, so a bare component reference can expand recursively. That is useful for logical documents, but it can also return more than an API endpoint is meant to expose. Prefer an explicit pattern when the external shape matters:
(d/pull db
[:order/id
{:order/items [:line/sku :line/qty]}]
[:order/id "o-1001"])
Map<?, ?> order =
conn.pull("[:order/id {:order/items [:line/sku :line/qty]}]",
List.of(":order/id", "o-1001"));
order = conn.pull('[:order/id {:order/items [:line/sku :line/qty]}]',
[":order/id", "o-1001"])
const order =
await conn.pull('[:order/id {:order/items [:line/sku :line/qty]}]',
[':order/id', 'o-1001']);
Appendix E has the pull pattern syntax reference table.
4. Choosing the Right Read API
Use these rules of thumb:
| Need | Use |
|---|---|
| You know a natural key and need one entity | Lookup ref with d/pull or d/entity |
| You know an entity id and need its data | d/pull by id, or d/entity by id for lazy navigation |
| You need a stable response map | d/pull with an explicit pattern |
| You need many response maps from known ids or lookup refs | d/pull-many |
| You need a bounded tree or hierarchy | d/pull with a recursive pattern such as {:category/children 2} |
| You are navigating conditionally | Entity API for lazy navigation, or d/pull when you need a realized map |
| You are debugging an entity at the REPL | d/entity plus d/touch |
| You want to stage a small update from an entity in Clojure | Transactable Entity with assoc, d/add, d/retract, or dissoc |
| You need to discover matching entities | d/q |
4.1 Read Performance
These APIs differ mostly in how much of the result shape is declared up front and how much per-call work they do.
d/pull is a good fit when you already know one entity and want one explicit map shape. Calling d/pull in a loop is correct, but it repeats the API call for each entity. In embedded use that overhead is often small, especially when the same pull pattern is reused and can be prepared from cache. In client/server use, a loop can be much more expensive because each call may be a separate request.
d/pull-many is the batch form for known entity ids or lookup refs. It prepares one pull pattern for the batch, returns results in the same order as the input ids, and uses one client/server request. Internally it still pulls each entity; it is not a different query optimizer. Prefer it over a loop when many known entities need the same shape.
d/entity is lazy navigation from one known entity. A single attribute read is an indexed lookup and the result is cached on that entity object. This can be efficient when control flow may read only one or two attributes. It is a poor fit for bulk response construction, because walking many entities or references can become many small indexed reads whose total count is less visible in the code.
d/q is the right starting point when the database must discover which entities match constraints. Among these read APIs, it is the one that uses the query optimizer: the engine can reorder clauses and use indexes to narrow the candidate set before returning values.
Pull is a shape tool, not always a shortcut
Do not treat d/pull as a guaranteed performance shortcut over d/q. Datalevin's query engine is optimized, and many pull-shaped reads can be expressed as Datalog queries that are just as efficient, or more efficient.
Internally, d/pull works over datoms, which have instantiation cost: attributes are realized into keywords and all values are materialized in memory, whereas d/q works on relations and does not realize data that is not bound to variables.
The main distinction is the shape of the problem:
d/entityis for lazy navigation from one known entity.- Transactable Entity is for staging small updates before
d/transact!. d/pullis for returning a declared nested map for known entities.d/qis for finding facts and entities that satisfy constraints.
Summary
Datalevin gives you several ways to read the same logical facts. Lookup refs let you use domain identifiers instead of internal ids. Entity APIs provide navigation for code that wants to move through references one step at a time, and transactable entities let that same Entity API stage small updates. Pull returns explicit plain maps for application boundaries. The APIs overlap by design, but they are not redundant: choose the one that matches whether you are identifying, shaping, discovering, navigating, or updating data.
User Examples
Log in to create examplesNo examples for this chapter yet.
