A type-safe SQL query builder and ORM for Rust, inspired by Drizzle ORM.
Warning
This project is still evolving. Expect breaking changes.
Add the library and install the CLI:
[dependencies]
drizzle = { git = "https://github.com/themixednuts/drizzle-rs", features = ["rusqlite"] }
# drivers: rusqlite | libsql | turso | postgres-sync | tokio-postgres
# optional: query (relational query API)cargo install drizzle-cli --git https://github.com/themixednuts/drizzle-rs --locked --all-featuresdrizzle init --dialect sqliteThis creates drizzle.config.toml. Point it at your schema and database:
dialect = "sqlite"
schema = "src/schema.rs"
out = "./drizzle"
[dbCredentials]
url = "./dev.db"Write your schema in src/schema.rs. Each #[SQLiteTable] also generates typed helpers for selecting, inserting, updating, and partial queries (see Generated Models).
use drizzle::sqlite::prelude::*;
#[SQLiteTable]
pub struct Users {
#[column(primary, autoincrement)]
pub id: i64,
pub name: String,
pub email: Option<String>,
pub age: i64,
}
#[SQLiteTable]
pub struct Posts {
#[column(primary, autoincrement)]
pub id: i64,
pub title: String,
pub content: Option<String>,
#[column(references = Users::id)]
pub author_id: i64,
}
#[derive(SQLiteSchema)]
pub struct Schema {
pub users: Users,
pub posts: Posts,
}Alternatively, use drizzle new for an interactive schema builder, or drizzle introspect to reverse-engineer a schema from an existing database.
drizzle generate # diff schema → SQL migration files
drizzle generate --name init # name the migration
drizzle migrate # apply pending migrations
drizzle push # skip migration files, apply schema diff directly
pushis useful during development. Usegenerate+migratefor production.
use drizzle::sqlite::rusqlite::Drizzle;
let conn = rusqlite::Connection::open("app.db")?;
let (db, Schema { users, posts }) = Drizzle::new(conn, Schema::new());See
examples/rusqlite.rsfor a full runnable example.
| Command | Description |
|---|---|
drizzle init |
Create a new drizzle.config.toml |
drizzle new |
Interactive schema builder (--json for JSON input) |
drizzle generate |
Diff schema and emit SQL migration files (--custom for an empty migration) |
drizzle migrate |
Apply pending migrations (--plan to preview, --safe to verify first) |
drizzle push |
Apply schema diff directly without migration files (--explain for dry run) |
drizzle introspect |
Reverse-engineer schema from a live database (--init to baseline) |
drizzle status |
Show which migrations have been applied |
drizzle check |
Validate your config file |
drizzle export |
Print the schema as raw SQL (--sql file.sql to write to file) |
drizzle up |
Upgrade migration snapshots to the latest format |
drizzle pullis an alias forintrospect. All commands accept-c <path>to use a custom config file and--db <name>for multi-database configs.
Each #[SQLiteTable] (or #[PostgresTable]) generates four helper types from your struct. Given:
#[SQLiteTable]
pub struct Users {
#[column(primary, autoincrement)]
pub id: i64,
pub name: String,
pub email: Option<String>,
pub age: i64,
}| Model | Purpose | Fields |
|---|---|---|
SelectUsers |
Query results | Matches the table columns exactly |
InsertUsers |
Insert rows | new(name, age) requires non-default fields; with_email(...) for optional ones |
UpdateUsers |
Update rows | default() starts empty; with_age(27) sets fields to update |
PartialSelectUsers |
Selective columns | All fields Option<T>; with_name() picks which columns to include |
new() takes only the required fields (columns without a default or autoincrement). Chain with_* methods for optional fields:
InsertUsers::new("Alex Smith", 26i64)
.with_email("alex@example.com")Start from default() and set only the fields you want to change. The query won't compile unless at least one field is set:
UpdateUsers::default()
.with_age(27)
.with_email("new@example.com")Pick specific columns to return. Unselected fields come back as None:
let partial: Vec<PartialSelectUsers> = db
.select(PartialSelectUsers::default().with_name().with_email())
.from(users)
.all()?;use drizzle::core::expr::*;// All rows
let all: Vec<SelectUsers> = db.select(()).from(users).all()?;
// Single row with filter
let user: SelectUsers = db
.select(())
.from(users)
.r#where(eq(users.name, "Alex Smith"))
.get()?;
// Specific columns
let names: Vec<(String,)> = db
.select((users.name,))
.from(users)
.all()?;SELECT builders are expressions — pass them directly into comparisons and set operators:
let min_id = db.select(min(users.id)).from(users);
let newer: Vec<SelectUsers> = db
.select(())
.from(users)
.r#where(gt(users.id, min_id))
.all()?;
let exact_rows = db
.select((users.id, users.name))
.from(users)
.r#where(eq(users.name, "Alex Smith"));
let matched: Vec<SelectUsers> = db
.select(())
.from(users)
.r#where(in_subquery((users.id, users.name), exact_rows))
.all()?;// Single row
db.insert(users)
.value(InsertUsers::new("Alex Smith", 26i64).with_email("alex@example.com"))
.execute()?;
// Multiple rows — all rows must set the same optional fields
db.insert(users)
.values([
InsertUsers::new("Alex Smith", 26i64).with_email("alex@example.com"),
InsertUsers::new("Jordan Lee", 30i64).with_email("jordan@example.com"),
])
.execute()?;db.update(users)
.set(UpdateUsers::default().with_age(27))
.r#where(eq(users.id, 1))
.execute()?;db.delete(users)
.r#where(eq(users.id, 1))
.execute()?;let rows: Vec<SelectUsers> = db
.select(())
.from(users)
.order_by(asc(users.name))
.limit(10)
.offset(20)
.all()?;Multiple sort keys:
.order_by([asc(users.name), desc(users.age)])Aggregate functions and common SQL expressions:
// Aggregates
let total: (i64,) = db.select((count(users.id),)).from(users).get()?;
let oldest: (Option<i64>,) = db.select((max(users.age),)).from(users).get()?;
// Coalesce — first non-null value
let rows: Vec<(String,)> = db
.select((coalesce(users.email, "unknown"),))
.from(users)
.all()?;Available:
count,sum,avg,min,max,coalesce,abs,upper,lower,length, and more indrizzle::core::expr.
Each dialect provides cast target markers for use with cast():
use drizzle::core::expr::cast;
// SQLite
let age = cast(json_age, drizzle::sqlite::types::Integer);
// PostgreSQL
let age = cast(user.age, drizzle::postgres::types::Int4);You can also pass a string to
cast()when you need a custom SQL type name.
db.select((users.name, alias(count(users.id), "total")))
.from(users)
.group_by([users.name])
.having(gt(count(users.id), 1))
.all()?;Use #[derive(SQLiteFromRow)] to map columns from multiple tables into a flat struct.
#[from(Users)] sets the default source table for unannotated fields:
use drizzle::core::expr::eq;
use drizzle::sqlite::prelude::*;
#[derive(SQLiteFromRow, Debug)]
#[from(Users)]
struct UserWithPost {
#[column(Users::id)]
user_id: i64,
name: String,
#[column(Posts::id)]
post_id: i64,
#[column(Posts::content)]
content: Option<String>,
}
// Explicit ON condition
let rows: Vec<UserWithPost> = db
.select(UserWithPost::Select)
.from(users)
.left_join((posts, eq(users.id, posts.author_id)))
.all()?;
// Auto-FK: derives the ON condition from #[column(references = ...)]
let rows: Vec<UserWithPost> = db
.select(UserWithPost::Select)
.from(users)
.left_join(posts)
.all()?;Use a Tag to alias a table for self-joins and CTEs:
use drizzle::sqlite::prelude::*;
tag!(U, "u");
let u = Users::alias::<U>();
let rows: Vec<(i64,)> = db.select((u.id,)).from(u).all()?;Combine queries with union, union_all, intersect, and except:
let results: Vec<(String,)> = db
.select((users.name,))
.from(users)
.r#where(lte(users.age, 25))
.union(
db.select((users.name,))
.from(users)
.r#where(gte(users.age, 30))
)
.order_by(asc(users.name))
.all()?;
unionremoves duplicates. Useunion_allto keep them.
Requires the query feature. Fetches a table with its relations in a single query — no manual joins.
Relation methods are auto-generated from #[column(references = ...)] foreign keys. Given Posts.author_id → Users.id, calling users.posts() returns the reverse (one-to-many) relation and posts.author() returns the forward (many-to-one) relation.
// Users with their posts
let users = db.query(users)
.with(users.posts())
.find_many()?;
for user in &users {
println!("{}: {} posts", user.name, user.posts().len());
}.find_first() returns Option<QueryRow<...>> instead of Vec:
let user = db.query(users)
.with(users.posts())
.r#where(eq(users.name, "Alice"))
.find_first()?;Relations nest — fetch users with their posts and each post's comments:
let users = db.query(users)
.with(users.posts().with(posts.comments()))
.find_many()?;
let first_post = &users[0].posts()[0];
println!("{} comments", first_post.comments().len());Multiple relations on the same table:
let users = db.query(users)
.with(users.posts())
.with(users.invited_by())
.find_many()?;Supports where, order_by, limit, and offset on the root query:
let users = db.query(users)
.with(users.posts())
.r#where(gt(users.age, 25))
.order_by(asc(users.name))
.limit(10)
.find_many()?;Each table generates convenient type aliases for use in function signatures:
use schema::{UsersQueryRow, UsersWithPosts, QueryUsersPosts};
fn print_user_posts(user: &UsersQueryRow<UsersWithPosts>) {
println!("{} has {} posts", user.name, user.posts().len());
}Transactions auto-rollback on error or panic. Return Ok(value) to commit, Err(...) to rollback.
use drizzle::sqlite::connection::SQLiteTransactionType;
db.transaction(SQLiteTransactionType::Deferred, |tx| {
tx.insert(users)
.value(InsertUsers::new("Alice", 28i64))
.execute()?;
let all: Vec<SelectUsers> = tx.select(()).from(users).all()?;
Ok(all.len())
})?;Savepoints nest inside transactions — a failed savepoint rolls back without aborting the outer transaction:
use drizzle::sqlite::connection::SQLiteTransactionType;
use drizzle::core::error::DrizzleError;
let count = db.transaction(SQLiteTransactionType::Deferred, |tx| {
tx.insert(users)
.value(InsertUsers::new("Alice", 28i64))
.execute()?;
// This savepoint fails and rolls back, but the outer transaction continues
let _ = tx.savepoint(|stx| {
stx.insert(users)
.value(InsertUsers::new("Bad Data", -1i64))
.execute()?;
Err(DrizzleError::Other("rollback this part".into()))
});
// Alice is still inserted
tx.insert(users)
.value(InsertUsers::new("Bob", 32i64))
.execute()?;
let all: Vec<SelectUsers> = tx.select(()).from(users).all()?;
Ok(all.len())
})?;PostgreSQL uses
PostgresTransactionType(e.g.ReadCommitted,Serializable) instead.
Placeholders are created from columns — wrong bind types fail at compile time.
use drizzle::core::expr::eq;
let name = users.name.placeholder("name");
let find = db
.select(())
.from(users)
.r#where(eq(users.name, name))
.prepare();
let alice: Vec<SelectUsers> = find.all(db.conn(), [name.bind("Alice")])?;
let bob: Vec<SelectUsers> = find.all(db.conn(), [name.bind("Bob")])?;
// name.bind(42) — compile error: Integer is not compatible with TextPlaceholders work in update (and insert) models too:
let new_name = users.name.placeholder("new_name");
let target = users.id.placeholder("target");
let stmt = db
.update(users)
.set(UpdateUsers::default().with_name(new_name))
.r#where(eq(users.id, target))
.prepare();
stmt.execute(db.conn(), [new_name.bind("New Name"), target.bind(1)])?;Use
.prepare().into_owned()to convert a prepared statement into a self-contained value that can be stored or moved freely.
Same pattern as SQLite, but with Postgres macros.
// schema.rs
use drizzle::postgres::prelude::*;
#[PostgresTable]
pub struct Accounts {
#[column(serial, primary)]
pub id: i32,
pub name: String,
}
#[derive(PostgresSchema)]
pub struct Schema {
pub accounts: Accounts,
}// main.rs
mod schema;
use drizzle::postgres::sync::Drizzle;
use schema::*;
fn main() -> drizzle::Result<()> {
let client = postgres::Client::connect(
"host=localhost user=postgres password=postgres dbname=drizzle_test",
postgres::NoTls,
)?;
let (mut db, Schema { accounts }) = Drizzle::new(client, Schema::new());
db.create()?;
db.insert(accounts)
.value(InsertAccounts::new("Acme"))
.execute()?;
let rows: Vec<SelectAccounts> = db.select(()).from(accounts).all()?;
println!("accounts: {rows:?}");
Ok(())
}For async, use
drizzle::postgres::tokio::Drizzlewithtokio_postgres::connect.
For app startup, embed your generated ./drizzle folder at compile time and pass the resulting migrations into db.migrate(...).
use drizzle::migrations::Tracking;
let migrations = drizzle::include_migrations!("./drizzle");
db.migrate(&migrations, Tracking::SQLITE)?;Use Tracking::POSTGRES for PostgreSQL, and override the tracking table or schema when needed:
db.migrate(
&migrations,
Tracking::POSTGRES
.schema("drizzle")
.table("__drizzle_migrations"),
)?;migrate creates the tracking schema/table if needed and skips migrations that have already been applied.
If you want migration files generated during cargo build, you can keep ./drizzle in sync from build.rs instead of running drizzle generate manually.
Add the build-time crates:
[build-dependencies]
drizzle-migrations = { git = "https://github.com/themixednuts/drizzle-rs" }
drizzle-types = { git = "https://github.com/themixednuts/drizzle-rs" }Create build.rs:
use drizzle_migrations::build::{Config, Output, run};
use drizzle_types::Dialect;
fn main() -> Result<(), Box<dyn std::error::Error>> {
let cfg = Config::new(Dialect::SQLite)
.file("src/schema.rs")
.out("./drizzle");
cfg.watch();
match run(&cfg)? {
Output::NoChanges => {}
Output::Generated { tag, path, .. } => {
println!("cargo:warning=generated migration {tag} at {}", path.display());
}
}
Ok(())
}If your schema is split across files, add each source file with another .file(...) call, for example .file("src/schema/users.rs").file("src/schema/posts.rs"). The build helper only uses the files you list; it does not follow Rust mod declarations automatically.
For development, push skips migration files entirely — it introspects the live database, diffs it against your schema, and applies the changes directly.
let schema = Schema::new();
db.push(&schema)?;
pushis intended for rapid iteration. Usemigratewith versioned migration files for production deployments.
MIT — see LICENSE.