Drizzle RS
A type-safe SQL query builder and ORM for Rust, inspired by Drizzle ORM.
[!WARNING] This project is still evolving. Expect breaking changes.
Contents
- Getting Started
- Migrations
- Generated Models
- Querying
- Expressions
- Relational Queries
- Transactions
- Prepared Statements
- PostgreSQL
- CLI Reference
- License
Getting Started
1. Install
[]
= { = "https://github.com/themixednuts/drizzle-rs", = ["rusqlite"] }
= { = "0.39", = ["bundled"] }
# drivers: rusqlite | libsql | turso | postgres-sync | tokio-postgres
2. Initialize
This creates drizzle.config.toml. Point it at your schema and database:
= "sqlite"
= "src/schema.rs"
= "./drizzle"
[]
= "./dev.db"
3. Define Your Schema
use *;
If you already have a database, run drizzle introspect to reverse-engineer the schema instead of writing it by hand.
4. Connect & Query
use Drizzle;
let conn = open?;
let = new;
[!NOTE] See
examples/rusqlite.rsfor a full runnable example.
Migrations
You have two workflows for keeping migration files in sync with your schema. Pick one — both produce the same committed SQL; the difference is whether you regenerate by hand or let cargo do it.
| Workflow | Generate migrations | Best for |
|---|---|---|
| Manual | Run drizzle generate yourself |
Teams that want explicit control over when migrations are produced |
| Automatic | Regenerated when watched schema/config inputs change during cargo build |
Solo dev or small teams who want schema and migrations to stay in lockstep |
Both workflows apply migrations the same way — either with the CLI at deploy time, or from your app at startup. For local iteration without committed files at all, see Push (Dev Only).
Manual: Generate with the CLI
Run drizzle generate whenever you change your schema, then commit the resulting SQL files:
Automatic: Generate from build.rs
Add drizzle-migrations as a build dependency, then point it at your existing drizzle.config.toml. Migration files regenerate themselves whenever your schema changes — you commit them the same way as the manual workflow, you just never run drizzle generate by hand.
[]
= { = "https://github.com/themixednuts/drizzle-rs", = ["rusqlite"] }
= { = "https://github.com/themixednuts/drizzle-rs" }
= { = "0.39", = ["bundled"] }
use ;
cfg.watch() tells cargo to rerun build.rs whenever a schema file, drizzle.config.toml, or a referenced env var changes.
Applying Migrations
Once migration files exist, apply them one of three ways. They all use the same SQL files and tracking table — pick whichever fits your environment.
At deploy time, with the CLI:
At app startup, from your code:
use Tracking;
let migrations = include_migrations!;
db.migrate?;
Use Tracking::POSTGRES for PostgreSQL. Override the tracking table or schema when you need to:
db.migrate?;
During cargo build, by extending the build.rs from above. Set DRIZZLE_MIGRATE=1 in your dev environment and your local database stays in lockstep with the schema:
use Drizzle;
use ;
if var.is_ok
cfg.tracking() returns the same Tracking value the runtime path uses — just sourced from drizzle.config.toml instead of hardcoded.
migrate creates the tracking schema/table if needed and skips migrations that have already been applied. Without DRIZZLE_MIGRATE, cargo build only generates files and never touches the database.
Push (Dev Only)
let schema = new;
db.push?;
push skips migration files entirely and applies the live schema diff directly.
[!CAUTION]
pushis for local iteration only. It bypasses the migration tracking table and offers no audit trail. Never run it against a production database.
Generated Models
Given the schema above, each #[SQLiteTable] (or #[PostgresTable]) generates four helper types:
| Model | Purpose | Fields |
|---|---|---|
SelectUsers |
Full-row 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 |
Partial-column query results | All fields Option<T>; populated by db.query(users).columns(...) (see Relational Queries) |
Insert
new() takes only the required fields (columns without a default or autoincrement). Chain with_* for optional fields:
new
.with_email
Update
Start from default() and set only the fields you want to change. The query won't compile unless at least one field is set:
default
.with_age
.with_email
Querying
All comparison and expression functions used below (eq, gt, and, asc, count, etc.) live in drizzle::core::expr.
Select
// All rows
let all: = db.select.from.all?;
// Single row with filter
let user: SelectUsers = db
.select
.from
.r#where
.get?;
// Specific columns
let names: = db
.select
.from
.all?;
// Multiple conditions
let active_adults: = db
.select
.from
.r#where
.all?;
// Or
let rows: = db
.select
.from
.r#where
.all?;
Ordering, Limiting, Pagination
let rows: = db
.select
.from
.order_by
.limit
.offset
.all?;
// Multiple sort keys
.order_by
Group By
db.select
.from
.group_by
.having
.all?;
// Multiple group columns
db.select
.from
.group_by
.all?;
Insert
// Single row
db.insert
.value
.execute?;
// Multiple rows
db.insert
.values
.execute?;
[!IMPORTANT] In a multi-row insert, every row must set the same set of optional fields. Mixing
with_email(...)on some rows but not others is a compile error.
Update
db.update
.set
.r#where
.execute?;
Delete
db.delete
.r#where
.execute?;
Joins
Use #[derive(SQLiteFromRow)] to map columns from multiple tables into a flat struct. #[from(Users)] sets the default source table for unannotated fields:
use eq;
use *;
// Explicit ON condition
let rows: = db
.select
.from
.left_join
.all?;
// Auto-FK: derives the ON condition from #[column(references = ...)]
let rows: = db
.select
.from
.left_join
.all?;
Subqueries & Set Operations
SELECT builders are expressions — pass them directly into comparisons or IN:
let min_id = db.select.from;
let newer: = db
.select
.from
.r#where
.all?;
let exact_rows = db
.select
.from
.r#where;
let matched: = db
.select
.from
.r#where
.all?;
Combine queries with union, union_all, intersect, and except. union removes duplicates; union_all keeps them:
let results: = db
.select
.from
.r#where
.union
.order_by
.all?;
Aliases
Use a Tag to alias a table for self-joins:
use *;
tag!;
let u = ;
let rows: = db.select.from.all?;
Expressions
Aggregate functions and common SQL expressions:
// Aggregates
let total: = db.select.from.get?;
let oldest: = db.select.from.get?;
// Coalesce — first non-null value
let rows: = db
.select
.from
.all?;
Available in drizzle::core::expr:
- Comparisons —
eq,neq,gt,gte,lt,lte - Boolean —
and,or,not - Aggregates —
count,sum,avg,min,max - Null handling —
coalesce,is_null,is_not_null - Strings —
upper,lower,length - Math —
abs - Ordering —
asc,desc
Type Casting
Each dialect provides cast target markers for use with cast(). Pass a string when you need a custom SQL type name.
use cast;
// SQLite
let age = cast;
// PostgreSQL
let age = cast;
Relational Queries
Requires the query feature. Fetches a table with its relations in a single query — no manual joins.
Relation methods are generated from #[column(references = ...)]. Given Posts.author_id → Users.id, users.posts() is the reverse (one-to-many) and posts.author() is the forward (many-to-one).
let users = db.query
.with
.find_many?;
for user in &users
.find_first() returns Option<...>:
let user = db.query
.with
.r#where
.find_first?;
Nest relations:
let users = db.query
.with
.find_many?;
println!;
Filter and paginate the root query:
let users = db.query
.with
.r#where
.order_by
.limit
.find_many?;
Selecting Specific Columns
.columns(...) / .omit(...) return PartialSelectUsers — same shape as SelectUsers, but every field is Option<T>:
let users = db.query
.columns
.find_many?;
for u in &users
Result Types
.with(users.posts()) returns UsersWithPosts — base columns via deref, relation data on fields like user.posts:
Transactions
[!TIP] Transactions auto-rollback on error or panic. Return
Ok(value)to commit,Err(...)to rollback. No manual cleanup needed.
use SQLiteTransactionType;
db.transaction?;
Savepoints nest inside transactions — a failed savepoint rolls back without aborting the outer transaction:
use SQLiteTransactionType;
use DrizzleError;
let count = db.transaction?;
Prepared Statements
[!TIP] Placeholders are typed by the column they came from. Binding the wrong type fails at compile time, not at runtime.
use eq;
let name = users.name.placeholder;
let find = db
.select
.from
.r#where
.prepare;
let alice: = find.all?;
let bob: = find.all?;
// name.bind(42) — compile error: Integer is not compatible with Text
Placeholders work in update (and insert) models too:
let new_name = users.name.placeholder;
let target = users.id.placeholder;
let stmt = db
.update
.set
.r#where
.prepare;
stmt.execute?;
Use .prepare().into_owned() to convert a prepared statement into a self-contained value that can be stored or moved freely.
PostgreSQL
Everything above works with #[PostgresTable], #[derive(PostgresSchema)], and drizzle::postgres::{sync,tokio}::Drizzle. Transactions take PostgresTransactionType (e.g. ReadCommitted, Serializable) in place of SQLiteTransactionType.
use *;
use Drizzle;
let client = connect?;
let = new;
CLI Reference
Most projects only need these:
| Command | Description |
|---|---|
drizzle init |
Create drizzle.config.toml |
drizzle generate |
Diff schema and emit SQL migration files |
drizzle migrate |
Apply pending migrations |
drizzle push |
Apply schema diff directly without migration files |
drizzle introspect |
Reverse-engineer schema from a live database |
Other useful commands:
| Command | Description |
|---|---|
drizzle new |
Interactive schema builder |
drizzle status |
Show applied migrations |
drizzle check |
Validate config |
drizzle export |
Print schema as raw SQL |
drizzle up |
Upgrade migration snapshots to the latest format |
drizzle pull is an alias for introspect. All commands accept -c <path> for a custom config file and --db <name> for multi-database configs.
License
MIT — see LICENSE.