Over the past few months, I’ve been building Kova, a relational query builder and ORM for Rust targeting PostgreSQL and SQLite.
When working with databases in Rust, we usually have to pick between three compromises:
- Heavy runtime abstractions (
ActiveModel/ object graphs) that hide SQL behind session trackers and inviteN+1queries. - Deeply nested generic tuple trees where every joined table and selected column is encoded into the return type, great until a 5-table join produces 200 lines of compiler errors and explodes compile times.
- Procedural query macros, which require a live database or offline schema cache just to run
cargo check, and quickly turn into string stitching when you need dynamic filters.
I wanted something closer to how we actually write Rust and SQL: if you know SQL, you already know Kova.
- Macro-free queries & zero trait imports: Apart from
#[derive(Model)](orFromRow,Cte) on your structs, there are no query macros and nouse kova::prelude::*. Every operator lives as an inherent method on columns and AST nodes. - Flat, constant-width typestate: Kova checks SQL validity at the method call boundary and immediately erases phantom type markers (
EraseX) inside the AST. Query builders have a fixed generic width, meaning fast compile times and readable error messages (powered by#[diagnostic::on_unimplemented]).
edvong / kova
A macro-free, modern PostgreSQL and SQLite ORM for Rust with native binary JSONB and FTS5
Kova
In production, the traditional ORM is an architectural lie. It turns domain logic into an unreadable mess far worse than raw SQL, while choking the database with implicit queries and lock contention.
Industry veterans blame the object-relational impedance mismatch; modern developers claim Rust’s strictness makes ORMs impossible. Both excuses are an intellectual cop-out. Mapping flat rows to native structs has zero inherent friction. Rust’s compiler didn't create these problems—it simply exposed the rot. The true culprit is a data layer built on three bankrupt design dogmas:
1. The "Object Graph" Delusion Mainstream ORMs peddle a fantasy: that you can pretend the relational database doesn't exist, treat physical tables as a network of in-memory pointers, and hot-swap database engines at runtime behind a single watered-down dialect. Sustaining that illusion requires bloating the framework with lazy loading, session caches, units of work, and dirty checking. Half a century of battle-tested relational…
Here is a quick tour of the four core design ideas behind Kova.
1. Macro-Free Query DSL & Zero prelude::* Imports
Let’s start with a basic schema (User and Order). You only import the structs and SQL functions you actually use, no wildcard trait imports needed.
use kova::{Model, Options, Result}; use kova::dsl::func::{Count, Sum, Wildcard}; #[derive(Model)] pub struct User { #[kova(auto)] pub id: i32, pub name: String, pub bio: Option<String>, pub balance: f64, pub is_active: bool, } #[derive(Model)] pub struct Order { #[kova(auto)] pub id: i32, pub user_id: i32, pub amount: f64, pub is_active: bool, } async fn run_queries() -> Result<()> { let client = Options::from("host=127.0.0.1 user=postgres dbname=app") .max_size(16) .build()?; // 1. Batch insert & RETURNING // Empty slices short-circuit safely; large slices exceeding wire limits // (PG: 65,535 / SQLite: 32,766 params) are automatically chunked. let new_users = [ User { id: 0, name: "Alice".into(), bio: Some("Architect".into()), balance: 1000.0, is_active: true }, User { id: 0, name: "Bob".into(), bio: None, balance: 200.0, is_active: true }, ]; let ids = client .insert(&new_users[..]) .returning(User::id) .execute_in::<i32>() .await?; // 2. Ergonomic filtering with "&", "|", "!" // `User::bio` is `Option<String>`, yet accepts `&str` directly. // `User::balance` is `f64`, yet accepts `i16` via lossless widening. let active_users: Vec<User> = client .select::<User>() .filter(User::is_active.eq(true) & User::bio.eq("Architect") & User::balance.ge(100_i16)) .load() .await?; // 3. Joins, aggregations, and positional tuple extraction let stats = client .select::<User>() .inner_join::<Order>(Order::user_id.eq(User::id)) .columns((User::id, User::name, Sum(Order::amount), Count(Wildcard))) .filter(User::is_active.eq(true) & !User::name.like("%bot%")) .group_by((User::id, User::name)) .having(Sum(Order::amount).gt(50.0)) .order_by(Sum(Order::amount).desc()) .limit(10) .load_in::<(i32, String, f64, i64)>() .await?; Ok(()) } At the same time, Kova’s fixed-width typestate machine and capability traits catch invalid SQL at compile time without nested generic types:
// ❌ Compile error: UPDATE is in `Unset` state without `.set()`; `.execute()` doesn't exist yet. client.update::<User>().filter(User::id.eq(1)).execute().await; // ❌ Compile error: `on_conflict` requires `.do_nothing()` or `.update(...)` before executing. client.insert(&new_users[0]).on_conflict(User::id).execute().await; // ❌ Compile error: Aggregate functions do not implement `CanGroup`. client.select::<User>().group_by(Sum(Order::amount)); // ❌ Compile error: Non-nullable `User::name` rejects `Option`, preventing null pollution. client.select::<User>().filter(User::name.eq(Some("Alice"))); // ❌ Compile error: Lossy narrowing from `i64` to `i32` is blocked. client.select::<User>().filter(User::id.eq(100_i64)); 2. Single-Query Nested Views via Native Binary JSONB
A common reason people reach for heavy ORMs is to load nested data, for example, fetching a Post along with its Author and a Vec<CommentDto>, where each comment also includes its commenter.
Instead of firing $N+1$ queries or trying to fold flat LEFT JOIN rows in memory, Kova leans on the database's native JSON/JSONB aggregation. The database builds the tree in C, and Kova deserializes it in a single query and a single round-trip:
use kova::{FromRow, Model}; use serde::Deserialize; #[derive(FromRow)] pub struct PostView { pub id: i32, pub title: String, pub content: String, pub author: Author, pub comments: Vec<CommentDto>, } #[derive(Deserialize, FromRow)] pub struct CommentDto { pub id: i32, pub content: String, pub commenter: Author, } #[derive(Deserialize)] pub struct Author { pub id: i32, pub name: String, } On PostgreSQL, Kova uses JsonbBuildObject and JsonbAgg (automatically wrapping jsonb_agg in coalesce(..., '[]'::jsonb) so posts with zero comments decode cleanly into vec![] instead of failing on SQL NULL):
use kova::dsl::func::{JsonbAgg, JsonbBuildObject}; use kova::query::Select; let views = pg_client .select::<Post>() .columns(( Post::id, Post::title, Post::content, JsonbBuildObject((User::id, User::name)).alias(PostView::author), Select::from::<Comment>() .columns(JsonbAgg(JsonbBuildObject(( Comment::id, Comment::content, JsonbBuildObject((User::id, User::name)).alias(CommentDto::commenter), )))) .inner_join::<User>(Comment::user_id.eq(User::id)) .filter(Comment::post_id.eq(Post::id)) .alias(PostView::comments), )) .inner_join::<User>(Post::author_id.eq(User::id)) .load_as::<PostView>() .await?; On SQLite (3.45+), you can assemble payloads using binary JsonbObject and JsonbGroupArray, converting to text via Json(...) only at the outer projection edge:
use kova::dsl::func::{Json, JsonObject, JsonbGroupArray, JsonbObject}; use kova::query::Select; let views = sqlite_client .select::<Post>() .columns(( Post::id, Post::title, Post::content, Json(JsonbObject((User::id, User::name))).alias(PostView::author), Select::from::<Comment>() .columns(Json(JsonbGroupArray(JsonbObject(( Comment::id, Comment::content, JsonObject((User::id, User::name)).alias(CommentDto::commenter), ))))) .inner_join::<User>(Comment::user_id.eq(User::id)) .filter(Comment::post_id.eq(Post::id)) .alias(PostView::comments), )) .inner_join::<User>(Post::author_id.eq(User::id)) .load_as::<PostView>()?; 3. Respecting Physical Boundaries: Async PostgreSQL, Sync SQLite
Many libraries try to unify all databases behind an async trait. That makes sense for PostgreSQL, which communicates over a network socket, yielding the task while waiting on TCP I/O is exactly what async/await is for.
SQLite, however, is an in-process C library reading memory pages or local disk directly on the calling thread, often finishing in microseconds. Wrapping SQLite in an async interface means boxing closures, shipping them across threads via spawn_blocking, and waking an executor, overhead that often takes longer than the query itself.
Kova respects the physical boundary of each engine:
- PostgreSQL (
features = ["rustls"]) is nativelyasync, backed bytokio-postgres,deadpool-postgres, and automatic per-connection prepared statement caching (prepare_typed_cached). - SQLite (
features = ["sqlite"]) is natively synchronous, backed byrusqlite, statement caching (prepare_cached), zero-copyValueRefslice borrowing, and a lightweight built-in LIFO connection pool that opens connections outside the mutex lock.
Aside from .await, the query DSL is identical across both engines, while each engine exposes its own native superpowers, like PostgreSQL's JSONB operator chains (->, ->>, @>, ?|), 1D arrays, FOR UPDATE SKIP LOCKED, and set-returning functions (Unnest, GenerateSeries), or SQLite's FTS5 full-text search with bm25(), highlight(), and snippet():
// SQLite FTS5 full-text search with BM25 scoring & contextual snippets let hits = sqlite_client .select::<Post>() .columns(( Post::title, Post::content.highlight(1, "<b>", "</b>"), Post::content.snippet(1, "[", "]", "...", 10), )) .filter(Post::content.matches("Rust AND SQL")) .order_by(Post::content.bm25().weights([10.0, 2.5]).asc()) .load_in::<(String, String, String)>()?; 4. Solving Cargo Workspace Orphan Rules with Codec<X>
If you've ever built a multi-crate Rust workspace, you've probably wanted a pure models crate shared across services, containing types like uuid::Uuid, url::Url, chrono::DateTime<Utc>, or custom domain structs like Decimal and ProductSpec.
With traditional ORMs, Rust's Orphan Rules (E0117) get in the way: you can't implement the ORM's Encode/Decode traits for Uuid in your service crate because both the trait and the type are foreign. You're forced to either rely on hardcoded feature flags inside the ORM crate, or wrap every field in a DbUuid(Uuid) newtype inside your models crate.
Kova solves this by parameterizing its codec and capability traits over a context type X:
// 1. In your standalone `models` crate: // Zero database driver dependencies, zero newtype wrappers. #[derive(Model)] pub struct Product { pub id: Uuid, pub price: Decimal, pub vendor_url: Url, pub specs: ProductSpec, pub created_at: DateTime<Utc>, } #[derive(Model)] generates an implementation generic over X. Then, in your downstream service crate, you define a local marker pub struct AppCodec;. Because AppCodec is local to your crate, you can implement zero-copy codecs, SQL capabilities (CanMath, CanString, CanPgJsonb), and cross-type compatibility rules for any external or domain type:
// 2. In your downstream `app-service` crate: pub struct AppCodec; impl<'a, 'b: 'a> Encode<'a, AppCodec> for &'b Uuid { fn encode(self) -> Result<Value<'a>> { // Zero-copy 16-byte slice borrow directly from Uuid Ok(Value::Blob(Cow::Borrowed(self.as_bytes()))) } } // (+ Encode for Uuid, Decode for Uuid, and `decode_column!(AppCodec, Uuid, ...);`) // Grant SQL capabilities & cross-type parameter rules under AppCodec: impl CanMath<AppCodec> for Decimal {} impl Compatible<Static<Decimal>, AppCodec> for Static<i64> {} impl CanString<AppCodec> for Url {} impl<'a> Compatible<Static<Url>, AppCodec> for Static<&'a str> {} impl CanPgJsonb<AppCodec> for ProductSpec {} Bind AppCodec to your client (a zero-cost phantom marker swap), and your custom types behave just like built-in primitives:
let client = base_client.codec::<AppCodec>(); client .update::<Product>() .set(( Product::price.increment(500_i64), // Increment Decimal directly by i64 Product::vendor_url.set("https://apple.com"), // Pass &str directly to a Url column Product::specs.set(&new_specs), // Write ProductSpec as JSONB )) .filter( Product::id.eq(&target_uuid) // Zero-copy 16-byte slice bind & Product::vendor_url.ilike("https://%") // Call ILIKE directly on a Url column & Product::specs.json_path_text("warranty").eq("2_years") ) .execute() .await?; Explore More on GitHub
This post only scratches the surface. If you're curious about:
- Compile-time
TypeIdtable tags (.tag::<Buyer>()) for self-joins and correlated subqueries without string aliases, - Window functions (
OVER,PARTITION BY,Lag/Lead, frame bounds) and CTEs (#[derive(Cte)]), - Closure-driven transactions with Higher-Rank Trait Bounds (HRTBs) that statically prevent transaction handles from escaping,
- Or the deeper architectural trade-offs behind
EraseXand tuple polymorphism,
check out the full documentation and test suite in the repository:
edvong / kova
A macro-free, modern PostgreSQL and SQLite ORM for Rust with native binary JSONB and FTS5
Kova
In production, the traditional ORM is an architectural lie. It turns domain logic into an unreadable mess far worse than raw SQL, while choking the database with implicit queries and lock contention.
Industry veterans blame the object-relational impedance mismatch; modern developers claim Rust’s strictness makes ORMs impossible. Both excuses are an intellectual cop-out. Mapping flat rows to native structs has zero inherent friction. Rust’s compiler didn't create these problems—it simply exposed the rot. The true culprit is a data layer built on three bankrupt design dogmas:
1. The "Object Graph" Delusion Mainstream ORMs peddle a fantasy: that you can pretend the relational database doesn't exist, treat physical tables as a network of in-memory pointers, ...
Related posts

Before Your Next System Design Interview, Open These 3 Free Resources

System Design, Plainly — Part 5: CAP and PACELC

I built GenerateRealData, a test data generator where keys, totals and rules hold across tables

The Database Scaling Ladder: Fix a Slow Database in the Right Order

How Flickr modernized caching with Amazon ElastiCache for Valkey
