Contents
- SQL Function Reference
- Function Naming Convention
- Transaction Functions
- Query Functions
- mentat.q(query, inputs) / edn_q(query, inputs)
- edn_q_rows(query, inputs DEFAULT '{}') – streamed rows
- mentat_tune_indexes(dry_run DEFAULT true) – automatic indexes
- mentat.explain(query) / mentat_explain(query)
- mentat_query_sql(query) — Generated SQL
- mentat_query_view(name, query) — Create SQL VIEW from Datalog
- Pull Functions
- Schema Functions
- Store Management
- Time Travel Functions
- Excision Functions
- Subscription Functions
- Materialized View Functions
- Statistics & Monitoring
- EDN Helper Functions
- Bootstrap Functions
SQL Function Reference
All pg_mentat functions live in the mentat schema. After CREATE EXTENSION pg_mentat, they are accessible as mentat.function_name().
Function Naming Convention
pg_mentat provides two sets of function names:
Convenience aliases (recommended for everyday use in the default mentat schema):
SELECT mentat.t('[{:person/name "Alice"}]');
SELECT mentat.q('[:find ?e :where [?e :person/name "Alice"]]');
SELECT mentat.pull('[*]', 10001);
Core functions use the cross-backend edn_* names shared with the SQLite
and DuckDB extensions (since 1.9.0):
SELECT edn_t('[{:person/name "Alice"}]');
SELECT edn_q('[:find ?e :where [?e :person/name "Alice"]]', '{}');
SELECT edn_pull('[*]', 10001);
SELECT edn_eval('(+ 1 2)'); -- only in builds with the `script` feature
Deprecated names. Before 1.9.0 these were mentat_transact, mentat_query,
mentat_pull and mentat_eval. The old names still work: they are thin SQL
wrappers over the edn_* functions (COMMENTed as deprecated) and will be
removed in a future major release. Other functions keep their mentat_ prefix.
Quick Reference
| Convenience alias | Full function | Description |
|---|---|---|
mentat.t(edn) |
edn_t(edn) |
Transact EDN data |
mentat.q(query, inputs) |
edn_q(query, inputs) |
Run a Datalog query |
| – | edn_q_rows(query, inputs) |
Run a query, one JSONB row per result |
mentat.pull(pattern, eid) |
edn_pull(pattern, eid) |
Pull entity attributes |
mentat.pull_many(pattern, eids) |
mentat_pull_many(pattern, eids) |
Pull multiple entities |
mentat.entity(eid) |
mentat_entity(eid) |
All attributes as JSON |
mentat.schema() |
mentat_schema() |
Current schema |
mentat.explain(query) |
mentat_explain(query) |
Show generated SQL |
mentat.stats() |
mentat_query_stats() |
Execution statistics |
mentat.storage() |
mentat_storage_stats() |
Storage statistics |
mentat.cache_stats() |
mentat_stmt_cache_stats() |
Statement cache info |
mentat.cache_clear() |
mentat_stmt_cache_clear() |
Clear statement cache |
Transaction Functions
mentat.t(edn) / edn_t(edn)
Execute a transaction. Returns a JSON transaction report:
{"db-before": {"basis-t": 1000000000001}, "db-after": {"basis-t": 1000000000002},
"tx-data": [[e, a, v, tx, added], ...], "tempids": {"tempid-1": 1000003},
"schema_changed": false, "datoms_asserted": 2, "datoms_retracted": 0}
datoms_asserted / datoms_retracted count the datoms the transaction wrote,
leaving out its :db/txInstant; both are 0 when the transaction changed
nothing (e.g. it re-asserted values already there). The embedded engine’s
TxReport and the DuckDB extension’s edn_t report the same counts.
edn_t runs in the caller’s transaction, at its isolation level. Under READ
COMMITTED (the default) and SERIALIZABLE, concurrent transactions keep a
cardinality-one attribute to one live value, :db/cas is atomic, a
:db/unique value stays on one entity, and a schema alteration to
:db.cardinality/one or :db/unique can’t race a write: a second writer of
the same value waits for the first to commit, then replaces its value (or its
cas fails). Under SERIALIZABLE it may get a serialization failure instead;
retry. Avoid REPEATABLE READ. There a racing replacement or cas fails with a
serialization error, but two first assertions of one attribute on one entity,
two new entities claiming one unique value, or a write racing a many-to-one
alteration can both commit and leave two live values. See “Transaction
isolation” in crates/pg/pg_mentat/sql/README.md.
Three things read the database before those locks are taken: a transaction
function (:db/fn) while it builds its tx-data, :db/retractEntity when it
lists the entity’s datoms, and a lookup ref ([:attr value]) when it
resolves. Under READ COMMITTED they can act on a value another transaction
is changing: a tx-function increment that returns a plain assertion loses
updates, and a write that races a retractEntity can survive it. Run
read-modify-write through them at SERIALIZABLE (and retry on 40001), or
have the tx-function return a :db/cas, which is compared after the locks.
SELECT mentat.t('[
{:db/id "tempid-1"
:person/name "Alice"
:person/age 30}
]');
The t alias transacts against the default store. Use the full function with a store argument for named stores:
SELECT mentat.mentat_transact_store('analytics', '[{:event/type "click"}]');
mentat_with(edn) — Speculative Transaction
Execute a transaction without persisting it. Returns the same report format, but writes nothing. Useful for validation or “what-if” analysis.
SELECT mentat.mentat_with('[
{:person/name "Test" :person/age 99}
]');
Query Functions
mentat.q(query, inputs) / edn_q(query, inputs)
Execute a Datalog query. Returns JSONB with columns and results.
-- Simple query
SELECT mentat.q('
[:find ?name ?age
:where [?e :person/name ?name]
[?e :person/age ?age]
[(> ?age 21)]]
');
-- With input bindings (positional, matching :in clause order)
SELECT mentat.q('
[:find ?name
:in $ ?min-age
:where [?e :person/name ?name]
[?e :person/age ?age]
[(>= ?age ?min-age)]]
', '[25]');
The inputs parameter is a JSON value:
- Simple array for positional bindings: '[25]'
- Empty for no inputs: '{}' or '[]'
- An object with the query options: inputs,
asOf, since, history, limit, offset. Since 1.18.0 any other key,
or a non-integer asOf / since, is an error; before, it was ignored and
the query read current state ({"as_of": T} returned today’s values).
edn_q_rows(query, inputs DEFAULT '{}') – streamed rows
RETURNS SETOF JSONB: one JSON array per result row, in :find order,
with the same value encoding and the same inputs (:in bindings,
asOf / since / history, limit / offset) as edn_q. Use it when
the result is large – edn_q returns one JSONB value, which PostgreSQL
caps at 1 GB and which must be built in memory before the first row
reaches the client – or when you want to join / filter / aggregate the
rows in SQL:
SELECT r->>0 AS name, (r->>1)::int AS age
FROM edn_q_rows('[:find ?n ?a :where [?e :person/name ?n] [?e :person/age ?a]]') r
WHERE (r->>1)::int > 21;
Rows are read from a cursor in batches of 1000 into a tuplestore, which
PostgreSQL keeps in memory up to work_mem and spills to a temp file past
it, so backend memory does not grow with the result. mentat.max_result_rows
applies exactly as for edn_q (more rows than the limit is an error, not
a silent truncation), so both functions give the same answer for the
same settings; SET LOCAL mentat.max_result_rows = 0 for an unbounded
stream. Default store only (like edn_q).
mentat_tune_indexes(dry_run DEFAULT true) – automatic indexes
Report (dry run) or apply the automatic index manager’s creates / drops:
RETURNS TABLE(action, index_name, table_name, reason). See
Operations: automatic index management.
mentat.explain(query) / mentat_explain(query)
Show the generated SQL and PostgreSQL’s EXPLAIN output without executing the query.
SELECT mentat.explain('[:find ?name :where [?e :person/name ?name]]');
mentat_query_sql(query) — Generated SQL
Return only the generated SQL string (no execution, no EXPLAIN).
SELECT mentat.mentat_query_sql('[:find ?name :where [?e :person/name ?name]]');
mentat_query_view(name, query) — Create SQL VIEW from Datalog
Create a PostgreSQL VIEW backed by a Datalog query:
SELECT mentat.mentat_query_view('people_over_30', '
[:find ?name ?age ?email
:where [?e :person/name ?name]
[?e :person/age ?age]
[?e :person/email ?email]
[(> ?age 30)]]
');
-- Now use it like any SQL view
SELECT * FROM mentat.people_over_30 WHERE name LIKE 'A%';
Pull Functions
mentat.pull(pattern, eid) / edn_pull(pattern, eid)
Pull attributes for a single entity. Returns a nested JSON document.
-- Pull everything
SELECT mentat.pull('[*]', 10001);
-- Pull specific attributes with nested refs
SELECT mentat.pull('[
:person/name
:person/age
{:person/friends [:person/name :person/age]}
]', 10001);
-- Reverse lookup: who has this entity as a friend?
SELECT mentat.pull('[:person/name :person/_friends]', 10001);
-- With modifiers
SELECT mentat.pull('[
:person/name
{(:person/friends :limit 5 :as :top-friends) [:person/name]}
{(:person/_friends :as :admirers) [:person/name]}
]', 10001);
edn_pull(pattern, eid, options)
Pull on a historical basis (since 1.18.0). options takes edn_q’s temporal
keys: {"asOf": T} (the state after transaction T), {"since": S} (only the
current datoms transacted after S), or both (the assertions with S < tx <= T
not retracted by T). '{}' is the current state. Any other key is an error;
"history" does not apply to pull.
SELECT edn_pull('[:person/name :person/age]', 10001, '{"asOf": 1000}');
On a named store, pull(store, pattern, eid, options) takes the same
options; q(store, ...) uses it for a (pull ?e [...]) find under asOf /
since.
mentat.pull_many(pattern, eids) / mentat_pull_many(pattern, eids)
Pull the same pattern for multiple entities. Returns a JSON array.
SELECT mentat.pull_many('[:person/name :person/age]', ARRAY[10001, 10002, 10003]);
mentat.entity(eid) / mentat_entity(eid)
Return all current attributes for an entity as a flat JSON map (equivalent to pull('[*]', eid)).
SELECT mentat.entity(10001);
Schema Functions
mentat.schema() / mentat_schema()
Return the full schema as JSON, keyed by attribute ident.
SELECT mentat.schema();
Store Management
mentat.create_store(name, description)
Create a new isolated store with its own schema, tables, and indexes.
SELECT mentat.create_store('analytics', 'Event tracking store');
mentat.drop_store(name)
Drop a store and all its data (irreversible).
mentat.list_stores()
List all stores with metadata.
mentat.rename_store(old_name, new_name)
Rename an existing store.
Time Travel Functions
mentat.log(store, from_tx, to_tx)
Return the transaction log for a range of transactions.
SELECT mentat.log('default', 1000001, 1000010);
mentat.diff(store, from_tx, to_tx)
Compute the diff between two points in time — what was added and retracted.
SELECT mentat.diff('default', 1000003, 1000007);
Time-travel via query parameters
Pass as_of_tx or since_tx to query functions:
-- Query the database as of transaction 1000005
SELECT mentat.q('
[:find ?name :where [?e :person/name ?name]]
', '[]', 1000005, NULL);
Excision Functions
mentat_excise(entity_ids, store, reason)
Permanently remove entities from the database, including all history. This is the only operation that truly deletes data (GDPR compliance). The entities' partition must have allow_excision = true, and no other entity may still refer to them.
-- Remove all data for two entities of the default store
SELECT mentat_excise(ARRAY[1000042, 1000043]::BIGINT[]);
-- On a named store, with a reason for mentat.excision_log
SELECT mentat_excise(ARRAY[1000042]::BIGINT[], 'acme', 'GDPR request 17');
Excision deletes the entities' datoms from the log and from the current-state
projection, their text from the store’s fulltext table, their rows in the
default store’s vector and geometry tables, and an excised enum entity’s
:db/ident, all in the calling transaction. A materialized view
(materialize, or any CREATE MATERIALIZED VIEW over the store) keeps the
rows it computed until it is refreshed: run refresh(store, view) for each
one after excising. Before 1.19.0 excision left the current-state projection
in place, so queries, pull and entity still returned the excised values.
PostgreSQL itself can still hold copies of excised values for a while. The
deleted rows stay on disk as dead tuples until VACUUM reclaims them, and
planner statistics (pg_stats: most common values, histogram bounds) keep
sampled values until the next ANALYZE. Roles that can read pg_stats for
those tables can see them. To clear them at once, run VACUUM ANALYZE on the
store’s datoms_<type>_new, current_<type> and fulltext tables after
excising. WAL and existing backups keep their copies until they expire.
Excision sends no subscription notification. A subscribe listener keeps the
last payload it received, which can include the excised values, until the
next transaction on the store sends a new one.
Subscription Functions
mentat.subscribe(store, name, query [, token_only])
Subscribe to changes matching a Datalog query pattern. Uses PostgreSQL LISTEN/NOTIFY:
the channel is mentat_<name>. After each write to the store whose result
differs from the last one this session sent, the payload is the query
result as JSON. PostgreSQL limits a payload to just under 8000 bytes; a
larger result is sent as a token instead ({"tx": T, "hash": H,
"too_large": true}, see below), so the write still succeeds and the
listener re-runs the query.
SELECT mentat.subscribe('default', 'new_people',
'[:find ?e :where [?e :person/name]]');
-- In another session:
LISTEN mentat_new_people;
Who receives it. PostgreSQL has no access control on LISTEN: every
role that can connect to the database can LISTEN mentat_<name> and gets
every payload, whatever its privileges on the store. So the query result
reaches roles that have no USAGE on the store’s schema. To keep it to
the roles that can read the store, subscribe with token_only => true:
SELECT mentat.subscribe('acme', 'payroll', '[:find ?n :where [_ :p/name ?n]]',
token_only => true);
-- payload: {"tx": 1000000000105, "hash": "9a0364b9e99bb480dd25e1f0284c8555"}
The payload is then {"tx": T, "hash": H}: tx is the id of the
transaction’s last t() / edn_t() on this store, hash the md5 of the
result’s JSON text. tx is null when the write was a plain statement on
the store’s datoms view in a transaction that called neither on this
store. The listener re-runs the query itself, with its
own privileges. The channel name, and so the subscription name, is still
visible to every listener.
mentat.unsubscribe(store, name)
Remove a subscription.
Materialized View Functions
mentat.materialize(store, name, query)
Create a materialized view from a Datalog query for faster repeated access.
SELECT mentat.materialize('default', 'active_users',
'[:find ?e ?name :where [?e :person/name ?name] [?e :person/active true]]');
mentat.refresh(store, name)
Refresh a materialized view with current data.
Statistics & Monitoring
mentat.stats() / mentat_query_stats()
Query execution statistics: call counts, timing, cache hit rates.
mentat.storage() / mentat_storage_stats()
Storage statistics: row counts, table sizes, index sizes.
mentat.cache_stats() / mentat_stmt_cache_stats()
Prepared statement cache statistics.
mentat.cache_clear() / mentat_stmt_cache_clear()
Clear the statement cache.
mentat_health_check()
Extension health check (returns JSON with status, version, store count).
mentat_slow_queries(threshold_ms)
Return recently logged slow queries exceeding the given threshold.
EDN Helper Functions
These operate on EDN-formatted text values and are installed in the public schema for convenience.
| Function | Description |
|---|---|
edn_get(edn, key) |
Extract a value from an EDN map |
edn_nth(edn, index) |
Extract Nth element from an EDN vector |
edn_count(edn) |
Count elements in an EDN collection |
edn_keys(edn) |
Keys of an EDN map as a vector |
edn_values(edn) |
Values of an EDN map as a vector |
edn_contains(edn, key) |
Check if a map contains a key |
edn_type(edn) |
Type of an EDN value |
edn_pretty(edn, width) |
Pretty-print EDN with indentation |
Bootstrap Functions
mentat.bootstrap_schema()
Re-run the bootstrap schema installation. Called automatically during CREATE EXTENSION but can be invoked to repair a corrupted schema.