Embedded graph memory for AI agents, mobile apps, and the browser — the SQLite of bi-temporal graph databases
A tiny, self-contained graph database with Datalog queries and bi-temporal time travel. Think SQLite, but for connected data with full history.
Try it in your browser — no install needed →
Watch time travel in the visualizer → Scrub transaction time and valid time, and see the graph change.
Minigraf is a single-file embedded graph database that lets you:
- ✅ Query relationships with Datalog - Recursive rules, natural graph traversal
- ✅ Time travel through history - Bi-temporal queries (transaction time + valid time)
- ✅ Window functions -
sum/count/min/max/avg/rank/row-number/lag/lead :over (partition-by … :order-by …)in:findclauses - ✅ Bounded and ordered results -
:limit Nreturns at most N rows and stops the work once it has them;:order-by [?score :desc] :limit 10keeps only the top ten as it reads, and:offsetpages through an ordered answer - ✅ Prepared statements - Parse + plan once with
$slotbind tokens, execute thousands of times - ✅ Embed anywhere - Native, WASM, mobile, IoT - one
.graphfile - ✅ Zero configuration - Just
Minigraf::open("data.graph")and you're done
For example, (lag ?salary :over (:partition-by ?dept :order-by ?hire-date)) reads the preceding employee's salary within each department; the first row gets nil. lead reads the following row.
The unreleased v3.0.0 branch supports ordinary (avg ?score) (#777). It skips nil and nonnumeric values, counts only numbers, and returns a Float average or nil for an existing group without numbers. No matching rows produces no result row. It uses the same exact-total-then-divide arithmetic as window avg; a finite total can overflow before division. V2 requires :over for avg.
The unreleased v3.0.0 branch adds bounded sliding windows such as (avg ?salary :over (:partition-by ?dept :order-by ?hire-date :rows 2 preceding)) (#183). See the benchmark coverage and manual-case dispositions for the limits of nightly coverage (#836). Memory/scaling acceptance is checked separately from latency; see the acceptance bounds and validation status (#837).
The manual v7-to-v8 migration sweep (#838) measures native fixtures through 200k facts and browser fixtures through 20k facts, with separate read-only/reopen controls and bounded cursor churn. These measurements do not expand nightly coverage or establish cold-start and sustained-reader bounds.
The regression-history follow-up (#840) verifies numeric comparisons for all 427 native and 26 binding IDs, independent v3 history, scheduled main/v3 routes and isolated live notification delivery with cleanup. Raw evidence and offline verifiers are retained in Git. JVM/WASI and qualified mobile nightly expansion are tracked separately in #858.
The semantic permutation map (#842) ties excluded numeric, bind, join and forced-plan permutations to correctness tests and states what the natural-plan nightly controls do not isolate. It adds no nightly benchmark.
The failure-path correctness map (#843) ties invalid inputs, callback errors, corruption, failed-handle refusal, v1–v6 refusal and heavy WAL replay to the correctness tests that check their error codes and data. It adds no nightly benchmark; a heavy-replay test gap is #865.
The manual shape sweep (#841) examines native sort/window, set and UUID shapes, recursive controls, and sustained reader retention. The report separates query latency, process RSS, metered allocations, input limits and capped attempts. These observations do not establish universal bounds or expand nightly coverage.
On v3.0.0, an ascending value-only query such as [:find ?v :where [?e :score ?v] :order-by [?v] :limit 10] can read directly in index order and stop early when all committed :score history has one value kind (integer, float, or Boolean) and its source range has no pending writes. A query whose full order cannot be proved uses the existing sort (#641). Built-in count, count-distinct, sum, and avg can release completed groups when the pinned source proves their grouping order (#781). min/max also qualify when their arguments are proved Integer/Float/Boolean/nil; sum-distinct requires Integer/nil. A single Integer/Boolean/Float key (including mixed numeric groups) is supported. A longer effective tuple, including hidden :with keys, must reduce to one varying Integer/Boolean slot plus repeated occurrences and proved Integer/Boolean/nil constants. Any requested final Sort is retained for :with. Small grouped point queries pay proof startup cost; see the measurements. Memory follows the active group and batches; distinct members within one group still grow. A limit can stop after enough emitted groups, but dropped min/max groups do not count and a retained Sort drains the input. Callbacks, collect-set, aggregate+window queries and unproved arguments keep full-input evaluation. When every built-in window uses the same proved nonconstant partition key, the engine can also finish and release one partition at a time. Its memory follows the largest partition plus bounded batches; one growing partition still grows. Unpartitioned windows, incompatible keys, aggregate queries, callbacks and collect-set keep full-input evaluation. A surrounding sort or distinct operation can still require more memory.
On the unreleased v3 branch, selective in-memory ancestor queries can use entity probes instead of reading the whole edge attribute (#723). This is a query-planning optimization; it adds no storage index or configuration.
On v3.0.0, ordinary sum and sum-distinct skip nonnumeric values. Ordinary
min/max compare every non-nil type using the window value order, including
mixed integers/floats (#789). Existing all-nil min/max groups are still omitted;
no matching rows still returns no rows for these aggregates. count and
count-distinct continue to count non-nil values of every type. No v2 runtime
change is included.
Status: See ROADMAP.md for planned work and current direction.
The unreleased v3 branch also avoids resolving the same keyword literal on every row of an expression comparison such as [(= ?entity :alice)] (#774); see the benchmark report.
Datalog is fundamentally better for graphs than SQL-like languages:
- Recursive by design - Multi-hop traversals are natural, not an afterthought
- Simpler to implement - Smaller spec = more reliable, faster to production
- Perfect for temporal - Time is just another dimension in relations
- Proven at scale - 40+ years of research, production use (Datomic, XTDB)
- Graph-native - Facts (Entity-Attribute-Value) are literally edges
- LLM-friendly - The small, uniform grammar (
[?e :attr ?v]patterns, no JOIN variants, no subquery nesting) is easy for AI coding assistants to generate correctly from a few examples; the entire language fits in a system prompt
[dependencies]
minigraf = "2.0.5"Or via cargo:
cargo add minigrafRequires Rust 1.89 or newer (the minimum supported Rust version, set in Cargo.toml).
Bugs present in the current release, with affected versions, workarounds and fix versions, are listed in the pinned Known issues in the current release issue. The most important one on v2.x:
- Two values of the same attribute for one entity written in a single
transact(or retracted in a singleretract) can read back as one value (#371). Write or retract each value of a multi-valued attribute in its own call. The fix changes the file format and ships in v3.0.0.
v2.x gets data-integrity and security fixes for 12 months after v3.0.0 ships. See the support policy.
use minigraf::{FactFilter, LogWriter, Minigraf, OpenOptions};
// Open or create a file-backed database
let db = OpenOptions::new().path("myapp.graph").open()?;
// Add facts
db.execute(r#"(transact [[:alice :person/name "Alice"]
[:alice :person/age 30]
[:alice :friend :bob]
[:bob :person/name "Bob"]])"#)?;
// Query with Datalog
let results = db.execute(r#"
(query [:find ?friend-name
:where [:alice :friend ?friend]
[?friend :person/name ?friend-name]])
"#)?;
// Cursor — read a large answer in batches; the answer is fixed at open
let mut cursor = db.query("(query [:find ?name :where [?e :person/name ?name]])")?;
while let Some(batch) = cursor.next_batch(1000)? {
for row in batch.rows() { /* row[0] is ?name */ }
}
// Fact log — every assertion and retraction, in transaction order, without Datalog
let mut log = db.fact_log(&FactFilter::new().attribute_prefix(":person/")
8000
)?;
while let Some(batch) = log.next_batch(1000)? {
for rec in batch { /* rec.tx_count, rec.entity, rec.attribute, rec.value, rec.asserted */ }
}
// Read-only open — for backups and offline tools. Read-only handles share a file;
// a read-write handle (like `db` on myapp.graph) excludes them.
let archive = Minigraf::open_with_options("archive.graph", OpenOptions::new().read_only(true))?;
// Log writer — copy records into a new file with their transactions and valid-time
// windows kept; leave records out to purge them
let mut out = LogWriter::create("archive-clean.graph", OpenOptions::new())?;
for rec in archive.fact_log(&FactFilter::new())? {
let rec = rec?;
if rec.attribute != ":person/ssn" { out.append(&rec)?; }
}
out.advance_tx_count(archive.current_tx_count())?;
out.finish()?;
// Explicit transaction — all-or-nothing
let mut tx = db.begin_write()?;
tx.execute(r#"(transact [[:alice :person/age 31]])"#)?;
tx.commit()?;
// Time travel — query as of past transaction counter
db.execute("(query [:find ?age :as-of 1 :where [:alice :person/age ?age]])")?;
// Recursive rule — transitive reachability
db.execute(r#"(rule [(reachable ?a ?b) [?a :friend ?b]])
(rule [(reachable ?a ?b) [?a :friend ?m] (reachable ?m ?b)])"#)?;
// Prepared statement — parse + plan once, execute many times
use minigraf::BindValue;
let pq = db.prepare("(query [:find ?name :as-of $tx :where [$entity :person/name ?name]])")?;
let r1 = pq.execute(&[("tx", BindValue::TxCount(1)), ("entity", BindValue::Entity(alice_id))])?;
let r2 = pq.execute(&[("tx", BindValue::TxCount(2)), ("entity", BindValue::Entity(bob_id))])?;cargo run # interactive Datalog REPL
cargo test # run the test suite
cargo run < demos/demo_recursive.txt # recursive rules demoStorage key encoding and scalar decoding have Kani proofs; see test coverage for their scope and how to run them.
Queries without aggregates return one row per matching binding, including repeated :find tuples.
Add the bare :distinct flag to remove repeated
output tuples: (query [:find ?name :where [?e :person/name ?name] :distinct]).
It runs after aggregates and windows, before ordering and pagination (#493).
See a working implementation of temporal reasoning with Minigraf at github.com/adityamukho/temporal_reasoning — an AI agent that uses Minigraf's bi-temporal model to store, correct, and audit beliefs.
The time travel visualizer runs Minigraf in your browser and draws its history. Step through transactions, move the valid-time cursor, and see each fact version on a bitemporal map. It opens .graph files too. For example, see a salary that was recorded wrong and then corrected.
See the Datalog Reference for the complete syntax.
No other database offers this combination:
| Feature | Minigraf | XTDB | Cozo | Neo4j | SQLite |
|---|---|---|---|---|---|
| Query Language | Datalog | Datalog | Datalog | Cypher | SQL |
| Single File | ✅ Yes | ❌ No | ❌ No | ❌ No | ✅ Yes |
| Bi-temporal | ✅ Yes | ✅ Yes | ❌ No | ❌ No | |
| Embedded | ✅ Yes | ✅ Yes | ✅ Yes | ❌ No | ✅ Yes |
| Graph Native | ✅ Yes | ✅ Yes | ✅ Yes | ✅ Yes | ❌ No |
| Rust | ✅ Yes | ❌ Clojure | ✅ Yes | ❌ Java | ❌ C |
| WASM Ready | ✅ Yes (browser + WASI) | ❌ No | ❌ No | ✅ Yes |
| Platform | Tier | Package | Install |
|---|---|---|---|
| Rust (native) | 1 | minigraf on crates.io |
cargo add minigraf |
| Python | 1 | minigraf on PyPI |
pip install minigraf |
| Browser WASM | 1 (from v3.0.0) | @minigraf/browser on npm |
npm install @minigraf/browser |
| WASI | 2 (experimental) | @minigraf/wasi on npm |
npm install @minigraf/wasi |
| Node.js | 2 (experimental) | minigraf on npm |
npm install minigraf |
| Java/JVM | 2 (experimental) | io.github.project-minigraf:minigraf-jvm on Maven Central |
see docs |
| Android | 2 (experimental) | io.github.project-minigraf:minigraf-android (.aar) on Maven Central |
see docs |
| iOS / macOS | 2 (experimental) | .xcframework via Swift Package Manager (minigraf-swift) |
see docs |
| C / FFI | 2 (experimental) | header + tarball on minigraf-c releases | see docs |
Tier 1 bindings are fully tested and released at the same time as every core release. Tier 2 bindings are built and smoke-tested, released on a best-effort schedule, and experimental. The Rust crate and Python are Tier 1; browser WASM joins them from v3.0.0. A binding moves to Tier 1 when real users need it, including the project's own published tools. Tier 1 operating systems and filesystems are Linux (ext4, xfs), macOS (APFS) and Windows (NTFS) on local disk; NFSv4 is supported with caveats, and NFSv3 nolock is unsupported for multiple writers. Details: support tiers.
Embedded graph memory for agents, mobile, and the browser — SQLite's simplicity + Datomic's temporal model.
| Language | Package | Repo |
|---|---|---|
| Python | minigraf on PyPI |
minigraf-python |
| Node.js | minigraf on npm |
minigraf-node |
| Browser WASM | @minigraf/browser on npm |
minigraf-wasm |
| WASI | @minigraf/wasi on npm |
minigraf-wasm |
| Java | io.github.project-minigraf:minigraf-jvm on Maven Central |
minigraf-java |
| Android | io.github.project-minigraf:minigraf-android on Maven Central |
minigraf-android |
| iOS/macOS | Swift bindings | minigraf-swift |
| C | C bindings | minigraf-c |
See the Comparison page for detailed analysis including temporal vs. time-series databases.
Store what an agent believes, retract and correct without losing history, and replay past states to audit decisions. Every fact carries both transaction time (when it was recorded) and valid time (when it was true), so you can reconstruct the exact knowledge state at the moment of any past decision.
Pairs well with vector stores (GraphRAG pattern): the vector store answers "what is similar?"; Minigraf answers "what are the relationships, who recorded them, and what did we believe at time T?"
See it in the visualizer: what an agent believed when it made a recommendation, and the correction that followed (press → to step forward).
Offline-first storage with retroactive corrections — the bi-temporal model lets you correct a mis-entered value while preserving the original record (see a correction in the visualizer). Native Kotlin and Swift bindings ship as an Android .aar (Maven Central) and an iOS .xcframework (Swift Package Manager) via UniFFI. No Rust required.
// Android (Kotlin)
val db = MiniGrafDb.open(context.filesDir.absolutePath + "/myapp.graph")
db.execute("""(transact [[:alice :person/name "Alice"] [:alice :person/age 30]])""")
val json = db.execute("(query [:find ?name :where [?e :person/name ?name]])")// iOS (Swift)
let db = try MiniGrafDb.open(path: docsURL.appendingPathComponent("myapp.graph").path)
try db.execute(datalog: #"(transact [[:alice :person/name "Alice"] [:alice :person/age 30]])"#)
let json = try db.execute(datalog: "(query [:find ?name :where [?e :person/name ?name]])")See the Mobile Integration section for full setup and usage docs (Gradle config, SPM integration, error handling, threading).
Published as @minigraf/browser on npm (IndexedDB-backed, wasm-pack). WASI build (wasm32-wasip1) available as @minigraf/wasi on npm and as a GitHub Releases artifact (Wasmtime / Wasmer). See the Use Cases. The playground and the time travel visualizer are both built on @minigraf/browser.
Language bindings ship as minigraf on PyPI, minigraf on npm (Node.js native addon), io.github.adityamukho:minigraf-jvm on Maven Central, and a C header + prebuilt shared library on GitHub Releases. See the Use Cases.
Minigraf runs as:
- ✅ An embedded library
- ✅ A standalone binary (interactive REPL)
- ✅ Browser WASM —
@minigraf/browser(IndexedDB-backed,wasm-pack) - ✅ Server-side WASM —
wasm32-wasip1/ WASI (Wasmtime, Wasmer, Cloudflare Workers) - ✅ Android, iOS, Python, Node.js, Java, C — via UniFFI / napi-rs / cbindgen
Minigraf will not be (by design):
- Distributed — no clustering, no sharding, no replication; each agent instance owns its own
.graphfile - Client-server — no network protocol in core
- Billion-node scale — optimised for <1M nodes (like SQLite)
- A time-series database — Minigraf is a temporal database; see Comparison
See ROADMAP.md for planned work and current direction.
Performance envelope on the v3 branch (format v8), measured up to 10M facts on an otherwise idle server, two rounds (see BENCHMARKS.md for every shape, RSS and the method):
| Workload | 100K facts | 1M facts | 10M facts |
|---|---|---|---|
Point lookup (bound entity + attribute, incl. :as-of / :valid-at), p50 / p99 |
18–21 µs / 25–39 µs | 18–20 µs / 22–37 µs | 18–20 µs / 22–27 µs |
| Entity with 1,000 versions, p50 / p99 | 0.29–0.30 / 0.31–0.35 ms | 0.29–0.30 / 0.30–0.36 ms | 0.29–0.30 / 0.32–0.37 ms |
| Open a file (RSS after open: 3 MiB) | 0.12–0.16 ms | 0.25–0.27 ms | 0.13–0.23 ms |
| Checkpoint after 1 new fact, p50 / p99 | 0.7–0.9 / 1.4–1.6 ms | 0.9–1.8 / 2.7–2.8 ms | 1.1–1.5 / 2.2–2.9 ms |
| Attribute scan (N/6 rows) drained in batches of 1,000, p50 | 23 ms | 0.25 s | 2.5 s |
| Selective 2-clause join (1 row), p50 | 38 µs | 37 µs | 37 µs |
| Rule query with a constant (magic sets, 5–7 rows), p50 | 1.1 ms | 1.5 ms | 1.9 ms |
The table above is the #551 snapshot. A later focused UUID projection comparison measures the 1M-fact attribute scan at 262 → 136 ms p50 after batched dictionary reads (#530); the other table scales were not remeasured in that comparison.
Point lookups, time travel on one entity, and joins and rule queries that a constant makes selective stay flat at every tested size. Queries that read a whole attribute (scans, aggregates, joins with nothing selective) grow linearly with the facts they read, in bounded memory: the 1.67M-row scan at 10M facts holds one batch, 7 MiB. Windows and whole transitive closures hold their rows, and OpenOptions::query_memory_limit stops a query that passes it with QRY-012. Graphs over 10M facts are not tested.
File-backed databases limit string values to 4,068 bytes (WAL-003). In-memory databases have no limit.
Durability tuning: every write is fsync'd immediately by default (SyncMode::Full). Bulk loaders/migrations that can safely re-run from a checkpoint watermark on failure can trade that for throughput with OpenOptions::new().synchronous(SyncMode::Normal) — checkpoint() still fsyncs unconditionally in both modes. See the Performance Tuning for the full tradeoff and the write-batching pattern that pairs with it.
Configuration: docs/CONFIGURATION.md lists every OpenOptions setting, which ones query text can override, and how OpenOptions::allow_overrides lets the embedder refuse those keywords (API-025, #869).
Running Minigraf where data loss matters: docs/DURABILITY.md covers what survives a process crash and a power loss, recovery on open, supported filesystems and containers, backups and restores, verify() and rebuild_indexes(), upgrading from v2.x, and which errors mean retry, reopen or restore.
This is a solo-maintained project with a long-term vision. Read PHILOSOPHY.md and ROADMAP.md before proposing features.
See CONTRIBUTING.md for development setup, code standards, and the PR process.
The deterministic storage schedule suite (#760) documents the native/MSRV CI checks, replay commands and coverage limits.
Licensed under either of:
- Apache License, Version 2.0 (LICENSE-APACHE or http://www.apache.org/licenses/LICENSE-2.0)
- MIT license (LICENSE-MIT or http://opensource.org/licenses/MIT)
at your option.
Unless you explicitly state otherwise, any contribution intentionally submitted for inclusion in the work by you, as defined in the Apache-2.0 license, shall be dual licensed as above, without any additional terms or conditions.