spg-embedded
Embedded SQL database for Rust. Single-writer, WAL-backed,
zero external dependencies (no proc-macro2, no syn, no
tokio, no rusqlite). PG-wire compatible when paired with
spg-server.
Throughput
Numbers from cargo bench -p spg-embedded on
Apple M-series silicon. Reproduce with
crates/spg-embedded/benches/embedded.rs.
| Operation | Time/op | Ops/sec |
|---|---|---|
| INSERT (in-memory) | ~0.6 µs | ~1.7 M |
| INSERT (persistent, one fsync per row) | ~4 ms | ~250 |
| SELECT by PK (BTree seek) | ~1.7 µs | ~600 k |
| Vector kNN, k=10, dim=8 (HNSW) | ~1.9 µs | ~520 k |
Persistent INSERT is the worst case — one fsync per call.
For bulk loads, wrap many INSERTs in
db.with_transaction(|tx| { … }): the WAL fsyncs once at
COMMIT instead of per statement, and throughput approaches
the in-memory number.
use Database;
let mut db = open_path?;
db.execute?;
db.execute?;
let rows = db.query?;
# Ok::
Why
- Zero external dependencies. The whole stack (parser,
planner, executor, storage, WAL) lives inside the workspace.
No transitive
serdeblow-up, no proc-macro chains, no build-time async runtime. YourCargo.lockstays small. - Crash-safe by default. Every committed mutation
fsyncs before the call returns. Boot replays the WAL. Catalog snapshots use atomic rename. Tested under simulated half-write and torn-record failure modes. - Single-writer model. No MVCC, no deadlocks, no lost
updates. Concurrent reads share an
Arc<Mutex<Database>>. Writes serialize, but a single writer on modern hardware is faster than you'd expect. - PG-flavoured SQL. Real
JOINs, realFOREIGN KEY ... ON DELETE/UPDATE, realCHECK-less butNOT NULL/DEFAULT/AUTO_INCREMENT, real pgvector-styleVECTOR(N)with HNSW / SQ8 / HALF. 4-corpus regression at 100% pass (pg_regress144/144,pgvector63/63,mysql100%,duckdb100%). - First-class vector search. Built-in HNSW with
<->(L2),<#>(inner product),<=>(cosine) operators. No separate extension to install.
When to use
| Use case | spg-embedded fit |
|---|---|
| Replacing SQLite in a Rust binary | ✅ Cleaner API, vectors built-in, FK enforcement |
| Per-tenant local DB in a multi-tenant service | ✅ One file per tenant, snapshot to backup |
| Edge / desktop app with cold-tier data | ✅ Hot/cold tier moves data to disk automatically |
| ML inference with vector recall | ✅ HNSW + pgvector ops without an extension |
| OLAP / analytics over moderate datasets | ✅ Designed for it |
| Multi-master OLTP | ❌ Single-writer (axiom A1) |
| Triggers / stored procs / RLS | ❌ See PG_MIGRATION.md |
| Sub-millisecond latency at QPS > 100k | Probably need spg-server + connection pool |
Quick start (persistent)
use Database;
Typed queries
The spg_row! declarative macro generates a FromSpgRow
impl for a plain Rust struct. No proc-macros, no
#[derive(...)].
use ;
spg_row!
let mut db = open_in_memory;
db.execute.unwrap;
db.execute.unwrap;
let users: = db.query_typed.unwrap;
for u in &users
Transactions
use Database;
let mut db = open_path.unwrap;
db.with_transaction.unwrap;
The closure runs inside a BEGIN / COMMIT pair. Returning
Err from the closure triggers ROLLBACK automatically. The
WAL fsyncs once at COMMIT, not per statement — bulk loads
should go through this path.
Foreign keys (v7.6+)
# use Database;
# let mut db = open_in_memory;
db.execute.unwrap;
db.execute.unwrap;
db.execute.unwrap;
Full surface: [CONSTRAINT name] FOREIGN KEY (cols) REFERENCES tbl[(pcols)] [ON DELETE …] [ON UPDATE …] with
actions CASCADE | RESTRICT | SET NULL | SET DEFAULT | NO ACTION. Composite (multi-column) and self-referencing
FKs supported. ALTER TABLE … ADD/DROP CONSTRAINT …
supported.
Vectors
# use Database;
let mut db = open_in_memory;
db.execute.unwrap;
db.execute.unwrap;
// Nearest-neighbour query — pgvector-style.
let rows = db.query.unwrap;
Concurrency
Database is Send but not Sync. Share across threads
with Arc<Mutex<Database>>. The single-writer architecture
is the design (axiom A1) — see
STABILITY.md.
Background freezer (optional)
use ;
use ;
use Duration;
let db = new;
let freezer = spawn_background_freezer;
// ... do work ...
freezer.stop;
The freezer moves the oldest hot rows into compressed cold segments on disk. Reads transparently span both tiers. Cold segments persist across restarts via the catalog manifest.
Panic contract
execute()/query()calls never panic on user input. Malformed SQL, type mismatches, missing tables all returnErr(EngineError::…).- The library panics only on internal invariant violations (catalog snapshot magic mismatch, WAL record CRC sentinel corruption that survived boot-time validation). These represent silent disk corruption.
- Release profile uses
panic = abort— host dies fast on poisoned state. Build with--profile release-dbgfor unwind tables +catch_unwind.
License
MIT OR Apache-2.0