Rango ORM
A Django-inspired ORM for Rust. The goal is simple: abstract away the complexity of database queries and deliver a smooth, concise, and enjoyable experience — without sacrificing performance or security.
Philosophy
Rango is built on a simple idea: you define your data, Rango handles the rest.
No SQL to write by hand. No migration files to maintain. No boilerplate.
#![allow(unused)] fn main() { use rango::prelude::*; #[derive(Model)] struct Post { id: FieldUuid, title: FieldVarchar<1, 255>, body: FieldText, published: FieldBool, created_at: FieldDateTime, } }
rango makemigrations # generates SQL from your structs
rango migrate # applies pending migrations
That's it.
Key Features
- Auto-migrations — diff your structs, generate SQL, never write migrations by hand
- Type-safe query builder — filter, order, paginate with full compile-time safety
- Relations —
ForeignKey,ManyToMany,select_related,prefetch_related - Multiple backends — PostgreSQL and SQLite, same API
- Async-first — built on sqlx and tokio
- CLI tools —
init,makemigrations,migrate,export,import
How Rango compares
Rango targets Django-style ORM ergonomics on native async SQLx.
| Rango | Diesel | SeaORM | SQLx | Butane | |
|---|---|---|---|---|---|
| Kind | Django-style ORM | Query builder + ORM | Async ORM | Raw SQL toolkit (not an ORM) | ORM |
| Migrations | ✅ auto-generated from models (rango makemigrations) | ⚠️ Diesel CLI; SQL by hand | ⚠️ Rust files by hand; schema sync newer | ⚠️ sqlx-cli; SQL by hand | ✅ auto-generated from models |
| Model definition | #[derive(Model)] only | table! macro + struct | Entity + ActiveModel, more boilerplate | None | #[model] struct |
| Async | ✅ native (sqlx) | ⚠️ via diesel-async | ✅ | ✅ | ✅ since 0.8 |
| Compile-time column checks | ✅ generated typed columns (User::EMAIL): name and value type checked; string names still accepted | ✅ strong | ❌ runtime | ✅ query! macros, needs a dev DB | ✅ |
| Model hooks | ✅ ModelHooks | ❌ | ✅ ActiveModelBehavior | ❌ | ❌ |
| Relations loading | ✅ select_related / prefetch_related | Manual joins | ✅ | ❌ | ✅ |
Real ON DELETE CASCADE | ✅ | ✅ | ✅ | n/a | ✅ |
| Raw SQL escape hatch | ✅ raw* | ✅ | ✅ | ✅ it's the point | ✅ |
| Backends | PG, MySQL / MariaDB, SQLite | PG, MySQL, SQLite | PG, MySQL, SQLite | PG, MySQL, SQLite | PG, SQLite |
| Maturity / ecosystem | Early, API not stable | Large, production at crates.io | Medium, active | Large | Small |
Where Rango is weaker today
- Bulk reads are ~6% slower than hand-written sqlx on 1000-row selects (on par with SeaORM, Diesel is ~7% faster) — see benchmarks.
- Young project with a small ecosystem.
Comparison inspired by Shuttle's Best Rust ORMs (2024), extended with Butane and Rango.
Status
Rango is under active development. The API is not yet stable. PostgreSQL, SQLite and MySQL / MariaDB backends are functional.
License
MIT OR Apache-2.0, at your option.
Installation
Add to your project
Rango is pre-1.0: a 0.x minor release may still break the API. The package is rango-orm; the crate you import is still rango.
# Cargo.toml
[dependencies]
# PostgreSQL backend (default)
rango-orm = "0.2"
# SQLite backend
rango-orm = { version = "0.2", default-features = false, features = ["sqlite"] }
# MySQL 8.0+ / MariaDB 10.6+ backend
rango-orm = { version = "0.2", default-features = false, features = ["mysql"] }
tokio = { version = "1", features = ["full"] }
# Rango does not re-export these: add them yourself to build
# FieldUuid / FieldDateTime values (uuid::Uuid, chrono::Utc::now()).
uuid = { version = "1", features = ["v4"] }
chrono = "0.4"
# Needed for `FieldJson` values and for reading JSON columns from raw results.
serde_json = "1"
Several backends at once
You can enable more than one feature. use rango::prelude::* then holds the backend-agnostic items (models, field types, Error, ...), the connect_* functions and the pool types of every enabled backend, plus the ops (insert, get, atomic, QueryBuilder, ...) of one backend, chosen by priority: PostgreSQL if enabled, else SQLite, else MySQL.
The other backends' ops are always reachable by path: rango::postgres::*, rango::sqlite::*, rango::mysql::*.
#![allow(unused)] fn main() { use rango::prelude::*; // ops from Postgres (default feature is on) rango::sqlite::insert(&sqlite_pool, item).await?; // `Model::filter` comes from a per-backend trait; two of them in scope are ambiguous, so name it: let q = <Item as rango::sqlite::RangoFilterExt>::filter(&sqlite_pool); }
Install the CLI
cargo install rango-orm-cli
This installs the rango binary.
Initialize a project
rango init # SQLite by default
rango init --backend postgres # or mysql / mariadb
This creates:
rango.toml ← configuration
migrations/ ← SQL migration files (generated)
Requirements
- Rust 1.94+ (edition 2024)
- A database server for PostgreSQL / MySQL / MariaDB (SQLite needs nothing extra)
Supported database versions
| Database | Supported | Tested in CI | Why this minimum |
|---|---|---|---|
| PostgreSQL | 14+ | 14, 18 | gen_random_uuid() is built in from 13; 13 is end-of-life |
| MySQL | 8.0+ | 8.0, 8.4 | oldest version tested |
| MariaDB | 10.6+ | 10.6, 11.4 | oldest version tested |
| SQLite | 3.35+ | bundled 3.46 | RETURNING (3.35), UPDATE … FROM (3.33); sqlx bundles SQLite, so nothing to install |
Quick Start
This guide walks you through a complete Rango setup in under 5 minutes.
1. Initialize
cargo new my-app && cd my-app
rango init --backend sqlite
Add Rango to Cargo.toml:
[dependencies]
rango-orm = { version = "0.2", default-features = false, features = ["sqlite"] }
tokio = { version = "1", features = ["full"] }
anyhow = "1"
uuid = { version = "1", features = ["v4"] } # you add these two: Rango does not re-export them
chrono = "0.4"
2. Define a model
Create src/models.rs:
#![allow(unused)] fn main() { use rango::prelude::*; #[derive(Model)] pub struct User { pub id: FieldUuid, #[field(unique)] pub email: FieldEmail, pub name: FieldVarchar<1, 100>, pub active: FieldBool, pub created_at: FieldDateTime, } #[derive(Model)] pub struct Post { pub id: FieldUuid, pub author_id: ForeignKey<User>, pub title: FieldVarchar<1, 255>, pub body: FieldText, pub published: FieldBool, } }
3. Generate migrations
rango makemigrations
Output:
🔍 Scanning models in src... (backend: sqlite)
Found 2 model(s):
- user (5 columns)
- post (5 columns)
✅ Migration generated: migrations/0001_post_user.sql
📸 Snapshot updated.
Migration files are named NNNN_<tables>.sql, with the tables involved in alphabetical order.
4. Apply migrations
DATABASE_URL=sqlite://db.sqlite3 rango migrate
Output:
🔌 Connecting to database (sqlite)...
▶ Applying 0001_post_user.sql...
✅ 0001_post_user.sql
✅ Applied 1 migration(s).
5. Use the ORM
use rango::prelude::*; mod models; use models::User; #[tokio::main] async fn main() -> anyhow::Result<()> { let pool = connect_sqlite("sqlite://db.sqlite3").await?; // Insert let user = User { id: FieldUuid(uuid::Uuid::new_v4()), email: FieldEmail::new("alice@example.com")?, name: FieldVarchar::new("Alice")?, active: FieldBool(true), created_at: FieldDateTime(chrono::Utc::now()), }; let user = insert(&pool, user).await?; // Query let active_users = User::filter(&pool) .eq(User::ACTIVE, true) .order_by(User::NAME) .limit(10) .all() .await?; for u in &active_users { println!("{} — {}", u.name.as_str(), u.email.as_str()); } Ok(()) }
User::ACTIVE and User::NAME are typed columns generated by #[derive(Model)]: a typo or a wrong value type is a compile error. String names (.eq("active", true)) also work; they are checked against the model's schema at run time and an unknown column returns Error::Invalid.
Connecting to PostgreSQL or MySQL
connect_sqlite and connect_mysql take a URL; connect_postgres takes a DatabaseConfig (pool size, timeouts):
#![allow(unused)] fn main() { // PostgreSQL (default features of `rango-orm`) let pool = connect_postgres(&DatabaseConfig::from_url("postgres://user:pass@localhost:5432/mydb")).await?; // MySQL / MariaDB let pool = connect_mysql("mysql://user:pass@localhost:3306/mydb").await?; }
Configuration
Rango is configured via rango.toml at the root of your project.
rango.toml
[database]
backend = "sqlite" # "sqlite", "postgres", "mysql" or "mariadb"
# url is optional here — prefer DATABASE_URL env var for credentials
[models]
src = "src" # directory to scan for #[derive(Model)] (a path given to `rango makemigrations` overrides it)
[migrations]
dir = "migrations" # directory for generated SQL files
Database URL
The database URL is read from (in priority order):
--database-urlCLI argumentDATABASE_URLenvironment variableurlfield inrango.toml(local dev only — do not commit credentials)
rango migrate also reads MIGRATE_DATABASE_URL before all of these: see migrate.
# Recommended for production
export DATABASE_URL=postgres://user:password@host:5432/mydb
# For local dev
DATABASE_URL=sqlite://db.sqlite3 rango migrate
Backends
backend | URL format | Notes |
|---|---|---|
sqlite | sqlite://path/to/db.sqlite3 | In-memory: sqlite::memory: |
postgres | postgres://user:pass@host:5432/db | Also accepts postgresql:// |
mysql | mysql://user:pass@host:3306/db | MySQL 8.0+ |
mariadb | mariadb://user:pass@host:3306/db | MariaDB 10.6+; same driver as mysql |
When a command gets a database URL (-d or DATABASE_URL), the backend is taken from its scheme (postgres / postgresql, mysql / mariadb, sqlite), whatever backend says. backend is the fallback for a URL with another form, and decides which SQL makemigrations writes.
Table names
Tables are never prefixed. The table name is the #[model(table = "...")] value, or the snake_case of the struct name. rango makemigrations and the runtime use the same names.
[models] prefix was removed: makemigrations fails if it is still set. Use #[model(table = "...")] instead.
Connection pool (Postgres)
rango::connect_postgres(&config) reads its settings from DatabaseConfig:
#![allow(unused)] fn main() { let mut config = DatabaseConfig::from_env()?; config.statement_timeout_ms = Some(5_000); // cancel queries running longer than 5 s config.lock_timeout_ms = Some(2_000); // give up waiting for a lock after 2 s config.slow_query_threshold_ms = 200; // WARN log for queries slower than 200 ms let pool = rango::connect_postgres(&config).await?; }
| Field | Default | Notes |
|---|---|---|
max_connections / min_connections | 20 / 2 | |
connect_timeout | 10 s | Pool acquire timeout → Error::Timeout |
idle_timeout | 600 s | |
max_lifetime | 1800 s | Recycles old connections |
test_before_acquire | false | Saves a round-trip per acquire; enable behind proxies that drop idle connections |
statement_timeout_ms / lock_timeout_ms | server default | Sent at connection startup (PgBouncer: add them to ignore_startup_parameters) |
slow_query_threshold_ms | 500 |
Queries are logged through tracing on the sqlx::query target: every query at DEBUG, slow ones at WARN. Only SQL text is logged, never bound values. SQLite uses the same logging with a fixed 500 ms threshold.
Defining Models
A Rango model is a regular Rust struct annotated with #[derive(Model)].
#![allow(unused)] fn main() { use rango::prelude::*; #[derive(Model)] pub struct Article { pub id: FieldUuid, pub title: FieldVarchar<1, 255>, pub body: FieldText, pub published: FieldBool, pub created_at: FieldDateTime, } }
A model is a normal struct, so it can derive other traits next to Model, for example #[derive(Model, Clone, Debug)]. A struct with a FieldPassword field prints <redacted> for it in Debug. FieldPassword::new, verify and from_hash are documented in Field types.
Table name
By default, the table name is the snake_case of the struct name. Tables are never prefixed.
Article → article
Override with #[model(table = "...")]:
#![allow(unused)] fn main() { #[derive(Model)] #[model(table = "cms_articles")] pub struct Article { ... } }
Primary key
Declare a field named id — it becomes the primary key automatically:
#![allow(unused)] fn main() { pub id: FieldUuid, // UUID primary key (recommended) pub id: FieldAutoBigInt, // Integer generated by the database }
FieldAutoInt / FieldAutoBigInt hold None until the row is inserted; the database assigns the id and insert returns the model with Some(id). Build new rows with FieldAutoBigInt::default(). bulk_create takes models that all have an id, or none. ForeignKey<M> and ManyToMany<M> follow the primary key type of the target model (FieldUuid, FieldInt, FieldAutoInt, FieldBigInt or FieldAutoBigInt). Text or composite keys cannot be the target of a relation.
Or use #[field(primary_key)] for a custom name:
#![allow(unused)] fn main() { #[field(primary_key)] pub uuid: FieldUuid, }
Unsupported field types
Fields use Rango's Field* types, not bare Rust types. The derive points at the offending type and names the replacement:
error: `String` is not a Rango field type: use `FieldText` or `FieldVarchar<MIN, MAX>`. ...
--> src/models.rs:6:11
|
6 | name: String,
| ^^^^^^
| Rust type | Rango type |
|---|---|
String | FieldText or FieldVarchar<MIN, MAX> |
Vec<u8> | FieldBytes |
bool | FieldBool |
i16 / i32 / i64 | FieldSmallInt / FieldInt / FieldBigInt |
f32 / f64 | FieldFloat / FieldDouble |
Uuid | FieldUuid (ForeignKey<M> for a reference) |
DateTime<Utc> / NaiveDate / NaiveTime | FieldDateTime / FieldDate / FieldTime |
serde_json::Value | FieldJson |
Wrapping any of them in Option<..> makes the column nullable.
Building a model value
There is no generated constructor: the fields are the API, so every field is written out and the compiler tells you which one is missing. Field types convert from their Rust equivalent, so .into() is the short form, and new(..) validates (FieldVarchar, FieldEmail, FieldUrl):
#![allow(unused)] fn main() { let article = Article { id: uuid::Uuid::new_v4().into(), title: FieldVarchar::new("Hello")?, body: "text".into(), published: true.into(), created_at: chrono::Utc::now().into(), }; }
ForeignKey<M> and ManyToMany<M> are Clone / Debug (and ManyToMany is Default) whatever M is: M does not need to implement them. ForeignKey<M> is also Copy, Eq and Hash. ManyToMany::default() is an empty relation.
Nullable fields
Use Option<T> for nullable columns:
#![allow(unused)] fn main() { pub bio: Option<FieldText>, // NULL allowed pub score: Option<FieldInt>, // NULL allowed pub name: FieldVarchar<1, 100>, // NOT NULL }
Field attributes
#![allow(unused)] fn main() { #[field(unique)] // UNIQUE constraint #[field(index)] // creates an index #[field(column = "custom_name")] // override column name #[field(primary_key)] // mark as primary key #[field(auto_now_add)] // set to now() on INSERT (FieldDateTime only) #[field(auto_now)] // update to now() on every UPDATE #[field(default = "0")] // SQL default value }
Model attributes
#![allow(unused)] fn main() { #[model(table = "custom_name")] #[model(managed = false)] // exclude from migrations (external table) #[model(ordering = [asc("name"), desc("created_at")])] #[model(constraints = [ CheckConstraint::new("age >= 0").name("chk_age"), UniqueConstraint::on(&["email", "tenant_id"]).name("uniq_email_tenant"), ])] }
Implicit primary key
#[derive(Model)] requires a primary key. The attribute macro #[rango::model] adds one when missing, then derives Model.
Behavior
- If no primary key is declared, it injects
pub id: FieldUuidas the first field. #[rango::model(pk = "bigint")]injectsid: FieldAutoBigInt, assigned by the database on insert.#[rango::model(pk = "int")]injectsid: FieldAutoInt."uuid"is the default.- If a primary key already exists (
idfield or#[field(primary_key)]), nothing is injected. #[derive(Model)]is added unless already present, so the struct can still carry#[model(...)]and#[field(...)]attributes.
Use #[rango::model] as written. It is not imported in the prelude, on purpose, to avoid clashing with other model names.
The default is set per model, not in rango.toml. Cargo does not track that file, so builds would go stale when it changes.
Example
#[rango::model]
struct Article {
title: FieldVarchar<1, 200>,
body: FieldText,
}
// Article now has `pub id: FieldUuid`.
#[rango::model(pk = "bigint")]
struct Event {
name: FieldVarchar<1, 100>,
}
// Event has `id: FieldAutoBigInt`, assigned by the database on insert.
Field Types
All field types live in rango::prelude::*.
Primitive types
| Rust type | SQL (Postgres) | SQL (SQLite) | Notes |
|---|---|---|---|
FieldBool | BOOLEAN | INTEGER | |
FieldSmallInt | SMALLINT | INTEGER | |
FieldInt | INTEGER | INTEGER | |
FieldBigInt | BIGINT | INTEGER | |
FieldAutoInt | INTEGER GENERATED BY DEFAULT AS IDENTITY | INTEGER (rowid) | Primary key only, None until inserted |
FieldAutoBigInt | BIGINT GENERATED BY DEFAULT AS IDENTITY | INTEGER (rowid) | Primary key only, None until inserted |
FieldFloat | REAL | REAL | |
FieldDouble | DOUBLE PRECISION | REAL |
Text types
| Rust type | SQL (Postgres) | SQL (SQLite) | Notes |
|---|---|---|---|
FieldText | TEXT | TEXT | Unlimited length |
FieldVarchar<MIN, MAX> | VARCHAR(MAX) | TEXT | Compile-time length bounds |
FieldEmail | VARCHAR(254) | TEXT | |
FieldUrl | VARCHAR(2048) | TEXT | |
FieldPassword<MIN, MAX> | TEXT | TEXT | MIN / MAX bound the plain password, which is stored as an argon2id hash: the column is always TEXT (LONGTEXT on MySQL / MariaDB) |
FieldPassword::new(plain) checks the bounds and hashes. verify(&self, candidate) checks a candidate against the stored hash. hash() returns the hash string and from_hash(hash) wraps a hash you already have.
FieldPassword hashes with argon2id on every write. This is deliberately slow (about 200 ms per value in a debug build, much less in release), so a bulk insert of users pays it per row.
Numeric types
| Rust type | SQL (Postgres) | SQL (SQLite) | SQL (MySQL / MariaDB) | Notes |
|---|---|---|---|---|
FieldDecimal<P, S> | NUMERIC | NUMERIC | DECIMAL(P, S) | P = precision, S = scale. Wraps rango::Decimal (exact). Only MySQL / MariaDB enforce P and S in the column; on Postgres and SQLite the column is a plain NUMERIC, and FieldDecimal::from_decimal rescales the value to S in Rust. SQLite is exact up to 15 digits through the numeric column, beyond that the digits are kept as text |
FieldRange<MIN, MAX> | BIGINT | INTEGER | BIGINT | Checked when built (FieldRange::new) |
Working with decimals
rango::Decimal is rust_decimal::Decimal. Build one from an integer and a scale, or wrap it in the field with FieldDecimal::from_decimal, which rescales to S:
#![allow(unused)] fn main() { use rango::prelude::*; let price = FieldDecimal::<10, 2>::from_decimal(Decimal::new(1250, 2)); // 12.50 // Filters take a plain `Decimal`, typed column or string name. let pricey = Product::filter(&pool).gte(Product::PRICE, Decimal::new(2000, 2)).all().await?; // >= 20.00 let exact = Product::filter(&pool).eq("price", Decimal::new(1999, 2)).all().await?; // = 19.99 }
sum() and avg() return Result<Option<f64>> on every backend (the database casts the result to a float), so they are not exact. For an exact decimal aggregate the rule depends on the backend:
| Backend | min::<Decimal> / max::<Decimal> | Exact sum |
|---|---|---|
| PostgreSQL, MySQL / MariaDB | Compile and return an exact Decimal | Read the rows and add the Decimals in Rust |
| SQLite | Do not compile: Decimal cannot be decoded from SQLite | Same |
The portable way to get the exact extreme is to read the row that holds it:
#![allow(unused)] fn main() { let priciest = Product::filter(&pool).order_by("-fee").limit(1).one().await?; // Option<Product> let max_fee: Option<Decimal> = priciest.map(|p| p.fee.0); }
values() works too when you do not want the whole model. On SQLite a value with more than 15 significant digits is kept as text and sorts after the numbers. See Aggregations.
Date/time types
| Rust type | SQL (Postgres) | SQL (SQLite) |
|---|---|---|
FieldDateTime | TIMESTAMPTZ | TEXT (ISO 8601) |
FieldDate | DATE | TEXT |
FieldTime | TIME | TEXT |
Other types
| Rust type | SQL (Postgres) | SQL (SQLite) |
|---|---|---|
FieldUuid | UUID | TEXT |
FieldJson | JSONB | TEXT |
FieldBytes | BYTEA | BLOB |
Relation types
| Rust type | Description |
|---|---|
ForeignKey<M> | Foreign key to model M; the column has the type of M's primary key (UUID, INTEGER or BIGINT). M needs a FieldUuid, FieldInt, FieldAutoInt, FieldBigInt or FieldAutoBigInt key |
ManyToMany<M> | Many-to-many relation: pivot table auto-generated, its key columns follow both primary key types. Same target key types as ForeignKey |
Nullable
Wrap any field type in Option<T> to make it nullable:
#![allow(unused)] fn main() { pub score: Option<FieldInt>, // INTEGER NULL pub bio: Option<FieldText>, // TEXT NULL pub avatar: Option<FieldUuid>, // UUID NULL }
Relations
ForeignKey
A ForeignKey<M> stores the primary key of a related model. The column type follows the target's key: UUID for FieldUuid (the default), INT for FieldInt / FieldAutoInt, BIGINT for FieldBigInt / FieldAutoBigInt.
#![allow(unused)] fn main() { use rango::prelude::*; #[derive(Model)] pub struct User { pub id: FieldUuid, pub name: FieldVarchar<1, 100>, } #[derive(Model)] pub struct Post { pub id: FieldUuid, pub author_id: ForeignKey<User>, // → UUID NOT NULL pub title: FieldVarchar<1, 255>, } }
The column is stored as UUID (Postgres) or TEXT (SQLite). Every FK column is indexed.
Pointing at an integer key works the same way; the FK holds an i32 / i64:
#![allow(unused)] fn main() { #[derive(Model)] pub struct Author { pub id: FieldAutoBigInt, pub name: FieldVarchar<1, 100>, } #[derive(Model)] pub struct Book { pub id: FieldUuid, pub author_id: ForeignKey<Author>, // → BIGINT NOT NULL REFERENCES author(id) } let book_author = ForeignKey::<Author>::new(42_i64); }
Only UUID, integer and bigint primary keys can be the target of a ForeignKey or ManyToMany.
On delete
The database enforces the FK with a real REFERENCES … ON DELETE … clause, so deleting a parent costs one statement, with no per-row queries in Rust. Pick the action with #[field(on_delete = "...")]:
| Value | SQL | Deleting the parent… |
|---|---|---|
"cascade" (default) | ON DELETE CASCADE | deletes the children |
"set_null" | ON DELETE SET NULL | sets the children's FK to NULL; the field must be Option<ForeignKey<M>> (compile error otherwise) |
"set_default" | ON DELETE SET DEFAULT | sets the children's FK to the column default |
"restrict" | ON DELETE RESTRICT | fails with Error::ForeignKeyViolation while children exist |
"no_action" | ON DELETE NO ACTION | like restrict, checked at the end of the statement |
#![allow(unused)] fn main() { #[derive(Model)] pub struct Comment { pub id: FieldUuid, #[field(on_delete = "set_null")] pub author_id: Option<ForeignKey<User>>, #[field(on_delete = "restrict")] pub post_id: ForeignKey<Post>, } }
Changing on_delete produces a migration. ON UPDATE is always NO ACTION.
Self-references and cycles
A model can reference itself (parent_id: Option<ForeignKey<Category>>), and two models can reference each other. rango makemigrations creates the tables first and adds the FKs that point forward with ALTER TABLE … ADD CONSTRAINT afterwards (inline on SQLite). Make at least one FK of a cycle nullable, otherwise no row can be inserted first.
Querying with select_related
Load the related model in a single JOIN:
#![allow(unused)] fn main() { let results = Post::filter(&pool) .select_related(Post::AUTHOR_ID) .all() .await?; for post in &results { let author = post.related::<User>(); println!("{} by {}", post.title, author.name); } }
ManyToMany
Declare a ManyToMany<M> field — Rango auto-generates the pivot table.
#![allow(unused)] fn main() { #[derive(Model)] pub struct Article { pub id: FieldUuid, pub title: FieldVarchar<1, 255>, pub tags: ManyToMany<Tag>, } #[derive(Model)] pub struct Tag { pub id: FieldUuid, pub name: FieldVarchar<1, 50>, } }
This creates a pivot table article_tag with two key columns, each typed like the primary key it references (UUID here, INT or BIGINT for integer keys).
M2M operations
M2M works on every backend and takes a pool or a transaction (&mut **tx inside atomic()). add is idempotent (adding an existing link is a no-op).
#![allow(unused)] fn main() { // Add a relation M2M::<Article, Tag>::add(&pool, &article, &tag).await?; // Remove a relation M2M::<Article, Tag>::remove(&pool, &article, &tag).await?; // Get all tags for an article let tags = M2M::<Article, Tag>::all(&pool, &article).await?; // Get all articles for a tag (reverse) let articles = M2M::<Article, Tag>::reverse(&pool, &tag).await?; // Set exact relations (replaces all existing, in one transaction) M2M::<Article, Tag>::set(&pool, &article, &tags).await?; // Clear all relations M2M::<Article, Tag>::clear(&pool, &article).await?; // Check if relation exists let exists = M2M::<Article, Tag>::exists(&pool, &article, &tag).await?; }
Adding many links at once
M2M::add links one pair per call, and there is no bulk add: the pivot is not a model, so bulk_create does not apply. The pivot is a plain table, so write one multi-row INSERT with raw_execute. The table is <table_a>_<table_b> and the columns are <table_a>_id and <table_b>_id (table names, here article_tag, article_id, tag_id; from_ / to_ prefixes for a self-reference). The pair is the primary key, so ON CONFLICT DO NOTHING keeps it idempotent like add.
#![allow(unused)] fn main() { // PostgreSQL: `$n` placeholders. On SQLite and MySQL write `?` instead. let mut sql = String::from(r#"INSERT INTO "article_tag" ("article_id", "tag_id") VALUES "#); let mut params = Vec::new(); for (i, tag) in tags.iter().enumerate() { if i > 0 { sql.push_str(", "); } sql.push_str(&format!("(${}, ${})", 2 * i + 1, 2 * i + 2)); params.push(SqlValue::Uuid(article.id.0)); params.push(SqlValue::Uuid(tag.id.0)); } sql.push_str(" ON CONFLICT DO NOTHING"); raw_execute(&pool, &sql, params).await?; }
On MySQL quote the names with backticks and end the statement with ON DUPLICATE KEY UPDATE article_id = article_id. Only placeholders are built into the SQL string: the ids are bound. Keep each statement under the bind limit (see Bulk operations) and wrap several statements in atomic(). M2M::set is the better tool when you want to replace all links of one row.
Self-referencing ManyToMany
A model can point a ManyToMany at itself:
#![allow(unused)] fn main() { #[derive(Model)] pub struct User { pub id: FieldUuid, pub name: FieldText, pub followers: ManyToMany<User>, } }
The pivot table is user_user. Its two columns would clash if both were named user_id, so
they are from_user_id and to_user_id (only when both sides are the same table).
The relation is directional: M2M::<User, User>::add(&pool, &a, &b) stores a as from and
b as to. all(&a) returns the users a points to (b), and reverse(&b) returns the
users that point to b (a). Adding (b, a) as well makes the relation symmetric; Rango
never does that for you. prefetch_related(User::FOLLOWERS) follows the same direction as all.
prefetch_related
Load reverse foreign key relations in one batched query (no N+1):
#![allow(unused)] fn main() { let posts = Post::filter(&pool) .prefetch_related(Comment::POST_ID) .all() .await?; for post in &posts { let comments = post.prefetched::<Comment>(); // &[Comment], zero SQL println!("{} has {} comments", post.title, comments.len()); } }
Rango executes:
SELECT * FROM postsSELECT * FROM comments WHERE post_id IN (...)
Then merges in memory — no N+1, no magic.
ManyToMany fields work the same way: .prefetch_related(Article::TAGS) loads every article's tags in one query through the pivot table, then article.prefetched::<Tag>().
Nullable FK
#![allow(unused)] fn main() { pub author_id: Option<ForeignKey<User>>, // nullable FK }
Model Hooks
Hooks let you run custom logic before or after single-row database operations.
Implementing hooks
Hooks are opt-in. #[derive(Model)] emits an empty impl ModelHooks for you; add
#[model(hooks)] to write your own instead.
#![allow(unused)] fn main() { use rango::prelude::*; #[derive(Model)] #[model(hooks)] pub struct User { pub id: FieldUuid, pub email: FieldEmail, pub slug: FieldVarchar<0, 100>, } impl ModelHooks for User { fn before_save(&mut self) -> BoxFuture<'_> { Box::pin(async move { // Runs before every INSERT or UPDATE; the mutation is what gets written. self.slug = FieldVarchar::new(self.email.replace('@', "-at-"))?; Ok(()) }) } fn after_create(&self) -> BoxFuture<'_> { Box::pin(async move { println!("Welcome {}!", self.email); Ok(()) }) } } }
#[rango::model(...)] forwards the attribute: write #[model(hooks)] on the struct as well.
Available hooks
| Hook | Triggered |
|---|---|
before_save | Before INSERT or UPDATE |
after_save | After INSERT or UPDATE |
before_create | Before INSERT only |
after_create | After INSERT only |
before_update | Before UPDATE only |
after_update | After UPDATE only |
before_delete | Before DELETE |
after_delete | After DELETE |
Which operations fire hooks
| Operation | Order |
|---|---|
insert | before_save, before_create, SQL, after_create, after_save |
update | before_save, before_update, SQL, after_update, after_save |
delete | before_delete, SQL, after_delete |
get_or_create | create hooks on defaults: before_* always run before the statement, after_* only if a row was inserted |
update_or_create | create hooks or update hooks, depending on whether the row existed |
Bulk operations skip hooks.
bulk_create,bulk_updateandbulk_upsertnever call any hook (see Bulk operations). If a hook computes a field, such as theslugabove, compute it yourself before a bulk call or insert row by row.
Not fired (same as Django): query-builder update / delete and raw*, as well as the bulk operations above.
before_* hooks take &mut self, so they can change the model; after_* hooks see the row
returned by the database.
Errors
- All hooks are
asyncand returnResult<(), Box<dyn Error + Send + Sync>>. - A
before_*hook returningErrcancels the operation: no SQL runs. The caller getsError::Hook. - An
after_*hook returningErris returned to the caller asError::Hooktoo, but the statement has already run. Insideatomic()(or any transaction) the error rolls the write back. With a plain pool the write is already committed: useatomic()if anafter_*hook must be able to undo it. - Hooks never open a transaction by themselves.
#![allow(unused)] fn main() { impl ModelHooks for User { fn before_save(&mut self) -> BoxFuture<'_> { Box::pin(async move { if self.email.is_empty() { return Err("email cannot be empty".into()); } Ok(()) }) } } }
Constraints & Meta
Check constraints
#![allow(unused)] fn main() { use rango::prelude::*; #[derive(Model)] #[model(constraints = [ CheckConstraint::new("age >= 0").name("chk_age_positive"), CheckConstraint::new("price > 0").name("chk_price_positive"), ])] pub struct Product { pub id: FieldUuid, pub name: FieldVarchar<1, 255>, pub price: FieldDecimal<10, 2>, pub age_restriction: FieldInt, } }
Generates:
CREATE TABLE "product" (
"id" UUID PRIMARY KEY,
...
CONSTRAINT "chk_age_positive" CHECK (age >= 0),
CONSTRAINT "chk_price_positive" CHECK (price > 0)
);
Unique constraints
#![allow(unused)] fn main() { #[model(constraints = [ // Simple unique UniqueConstraint::on(&["email"]).name("uniq_email"), // Composite unique UniqueConstraint::on(&["room", "date"]).name("uniq_booking"), // Partial unique (partial unique index on Postgres and SQLite; skipped with a comment on MySQL) UniqueConstraint::on(&["email"]) .condition("deleted_at IS NULL") .name("uniq_active_email"), ])] }
Field-level unique
For single-column uniqueness, #[field(unique)] is simpler:
#![allow(unused)] fn main() { #[field(unique)] pub email: FieldEmail, }
Default ordering
#![allow(unused)] fn main() { #[model(ordering = [asc("name"), desc("created_at")])] pub struct Article { ... } }
This ordering is injected automatically into queries on this model.
Unmanaged models
Mark a model as managed = false to exclude it from migrations.
Useful for mapping external tables or views:
#![allow(unused)] fn main() { #[model(managed = false, table = "external_users")] pub struct ExternalUser { pub id: FieldUuid, pub email: FieldEmail, } }
Rango won't create or modify this table — you manage it yourself.
rango makemigrations
Scans your source code for #[derive(Model)] structs, diffs against the last known state, and generates SQL migration files.
Basic usage
rango makemigrations
Output:
🔍 Scanning models in src... (backend: postgres)
Found 3 model(s):
- user (5 columns)
- post (6 columns)
- tag (2 columns)
✅ Migration generated: migrations/0001_post_tag_user.sql
📸 Snapshot updated.
The file is named NNNN_<label>.sql. NNNN is the next free number (highest existing prefix plus one, four digits). <label> is the names of the tables the migration touches (including M2M pivot tables), sorted alphabetically and joined with _, for example 0002_event.sql or 0001_post_tag_user.sql. When that joined string is longer than 40 characters (typically when many tables change at once) the label is auto, for example 0001_auto.sql.
Options
# Preview SQL without writing files
rango makemigrations --dry-run
# Exit with code 1 if migrations are pending (for CI)
rango makemigrations --check
# Scan a specific directory
rango makemigrations src/models
# Custom output directory
rango makemigrations --output db/migrations
# Confirm every rename and drop without asking
rango makemigrations --yes
Renames
Renaming a field looks like a dropped column plus an added one. When a table has both in the same run, Rango pairs them (columns of the same type first) and asks:
Did you rename "user"."name" to "full_name"? [y/N]
Answer y and the migration contains ALTER TABLE "user" RENAME COLUMN "name" TO "full_name";, so the data is kept. Answer anything else and it stays a drop plus an add.
--yesanswersyto every rename question (and to the destructive-changes confirmation), for scripts. Review the SQL first with--dry-run.- Without a terminal (CI, pipes) Rango cannot ask, so each question counts as "no". If the new column is
NOT NULLwithout a default, the command stops with an error that mentions the possible rename and--yes. Otherwise a drop that needs confirmation aborts withAborted: destructive changes need confirmation (interactive, or --yes). - With
--check, renames are never confirmed: the run only reports whether migrations are pending.
How it works
- Rango scans your
src/directory for Rust files containing#[derive(Model)] - Extracts the schema (table name, columns, types, constraints) using
syn - Loads the previous schema snapshot from
migrations/snapshot.json - Computes the diff
- Generates a numbered
.sqlfile and updates the snapshot
Snapshot
The snapshot (migrations/snapshot.json) tracks the current known state of your schema. Commit this file — it's how Rango knows what has changed.
What gets detected
| Change | Generated SQL |
|---|---|
| New model | CREATE TABLE IF NOT EXISTS ... |
| Removed model | DROP TABLE IF EXISTS ... |
| New field | ALTER TABLE ... ADD COLUMN ... |
| Removed field | ALTER TABLE ... DROP COLUMN ... |
| Field type changed | ALTER TABLE ... ALTER COLUMN ... TYPE ... |
| Nullable changed | ALTER TABLE ... ALTER COLUMN ... SET/DROP NOT NULL |
Field renamed (confirmed at the prompt or with --yes) | ALTER TABLE ... RENAME COLUMN ... TO ... |
#[field(unique)] added or removed | ALTER TABLE ... ADD CONSTRAINT ... UNIQUE, or the backend's drop (SQLite: table rebuild) |
Index added or removed (#[field(index)], foreign keys) | CREATE INDEX IF NOT EXISTS ... / DROP INDEX ... |
Foreign key target or on_delete changed | DROP CONSTRAINT + ADD CONSTRAINT ... REFERENCES ... (SQLite: table rebuild) |
CheckConstraint / UniqueConstraint added, removed, or its SQL / columns changed under the same name | ALTER TABLE ... ADD/DROP CONSTRAINT ... (SQLite: table rebuild) |
Partial unique constraint (.condition(..)) added, removed or its condition changed | CREATE UNIQUE INDEX ... WHERE ... / DROP INDEX (Postgres and SQLite; skipped with a comment on MySQL / MariaDB) |
SQLite limitations
SQLite cannot change a column's type, nullability, FK or uniqueness, nor add or drop a constraint, in place. For those changes Rango rebuilds the table instead:
-- SQLite cannot alter columns or constraints in place: rebuild "user".
CREATE TABLE "_rango_new_user" (...);
INSERT INTO "_rango_new_user" ("id", "name", ...) SELECT "id", "name", ... FROM "user";
DROP TABLE "user";
ALTER TABLE "_rango_new_user" RENAME TO "user";
CREATE INDEX ...;
rango migrate runs it with foreign keys off, so dropping the old table never cascades
into child tables, then runs PRAGMA foreign_key_check before committing. A migration that
leaves a dangling FK, or copies a NULL into a new NOT NULL column, is rolled back.
CI guard
# .github/workflows/ci.yml
- name: Check no pending migrations
run: rango makemigrations --check
This fails if any model changes are not yet migrated — useful to catch forgotten makemigrations in PRs.
rango migrate
Applies pending SQL migration files to the database.
Basic usage
DATABASE_URL=postgres://user:pass@localhost/mydb rango migrate
Output:
🔌 Connecting to database (postgres)...
⏭ 0001_user.sql (already applied)
▶ Applying 0002_post.sql...
✅ 0002_post.sql
✅ Applied 1 migration(s).
Options
# Custom migrations directory
rango migrate --migrations db/migrations
# Custom database URL
rango migrate --database-url postgres://...
Migration tracking
Rango creates a _rango_migrations table in your database to track applied migrations:
-- Postgres
CREATE TABLE _rango_migrations (
id SERIAL PRIMARY KEY,
name TEXT NOT NULL UNIQUE,
applied_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- SQLite
CREATE TABLE _rango_migrations (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE,
applied_at TEXT NOT NULL DEFAULT (datetime('now'))
);
Order of execution
Migrations are applied in alphabetical order (which matches their numbered prefix):
0001_user.sql ← applied first
0002_post.sql ← applied second
0003_tag_post.sql ← applied third
Idempotency
Already-applied migrations are skipped. Running rango migrate twice is safe.
DATABASE_URL
The database URL is resolved in this order:
--database-urlCLI flagMIGRATE_DATABASE_URLenvironment variableDATABASE_URLenvironment variable
MIGRATE_DATABASE_URL lets migrations run as a role that owns the schema (DDL) while the
application's DATABASE_URL uses a role limited to reading and writing rows (DML).
Filter & Lookup
The query builder is chainable, lazy, and type-safe.
Basic filtering
#![allow(unused)] fn main() { // All active users let users = User::filter(&pool) .eq("active", true) .all() .await?; // Users with score > 100 let users = User::filter(&pool) .gt("score", 100) .all() .await?; }
Typed columns
#[derive(Model)] generates one constant per field (User::EMAIL, User::SCORE, …),
typed by model and field type (Column<User, FieldInt>). Use them instead of strings:
a typo, a renamed field, a column from another model, or a value of the wrong type
becomes a compile error instead of a database error.
#![allow(unused)] fn main() { let users = User::filter(&pool) .eq(User::ACTIVE, true) .gt(User::SCORE, 100) .order_by(User::CREATED_AT.desc()) .all() .await?; let total = Order::filter(&pool).sum(Order::PRICE).await?; // Compile errors: // User::filter(&pool).eq(Article::TITLE, "x"); // column of another model // User::filter(&pool).eq(User::SCORE, "high"); // `&str` is not a `FieldInt` value // User::filter(&pool).eq(User::PASSWORD, "pw"); // passwords are salted hashes }
A typed column accepts its field type or the plain Rust equivalent: i32 for FieldInt,
i64 for FieldBigInt and FieldRange, &str/String for FieldText, FieldEmail,
FieldUrl and FieldVarchar, Uuid or FieldUuid for ForeignKey<M>.
Filter values are not validated like field constructors: searching for
"not-an-email" is allowed.
String names ("email", "-created_at") are still accepted everywhere; they accept any
value and are checked by the database at runtime.
Operators
| Method | SQL | Example |
|---|---|---|
.eq(col, val) | col = val | .eq("role", "admin") |
.ne(col, val) | col != val | .ne("status", "banned") |
.gt(col, val) | col > val | .gt("age", 18) |
.gte(col, val) | col >= val | .gte("score", 100) |
.lt(col, val) | col < val | .lt("price", 50) |
.lte(col, val) | col <= val | .lte("stock", 0) |
.like(col, pat) | col LIKE pat | .like("name", "Al%") |
.ilike(col, pat) | col ILIKE pat | .ilike("email", "%@gmail%") |
.in_values(col, vals) | col IN (...) | .in_values("id", ids) |
.is_null(col) | col IS NULL | .is_null("deleted_at") |
.is_not_null(col) | col IS NOT NULL | .is_not_null("email") |
Logical connectors
#![allow(unused)] fn main() { // AND (default) User::filter(&pool) .eq("active", true) .eq("role", "admin") // AND // OR User::filter(&pool) .eq("role", "admin") .or() .eq("role", "moderator") // NOT User::filter(&pool) .not() .eq("banned", true) // Grouped OR User::filter(&pool) .eq("active", true) .group(|q| q.eq("role", "admin").or().eq("role", "mod")) // → WHERE "active" = $1 AND ("role" = $2 OR "role" = $3) }
Subqueries
Filter on another table without loading it. The closure gets an empty filter() on the other model and adds conditions to it; only those conditions are used (ordering, limit and relations are ignored). The foreign key column tells Rango which model the subquery runs on. Values stay bound parameters.
where_exists keeps rows that have at least one related row through a foreign key pointing at them:
#![allow(unused)] fn main() { // Users with at least one published article let authors = User::filter(&pool) .where_exists(Article::AUTHOR_ID, |q| q.eq(Article::PUBLISHED, true)) .all() .await?; // WHERE EXISTS (SELECT 1 FROM "article" // WHERE "article"."author_id" = "user"."id" AND ("published" = $1)) // Users without any article let idle = User::filter(&pool) .not() .where_exists(Article::AUTHOR_ID, |q| q) .all() .await?; }
in_subquery compares a column with the values another query returns:
#![allow(unused)] fn main() { // Posts written by active users let posts = Post::filter(&pool) .in_subquery(Post::AUTHOR_ID, User::ID, |q| q.eq(User::ACTIVE, true)) .all() .await?; // WHERE "author_id" IN (SELECT "id" FROM "user" WHERE "active" = $1) }
Both compose with .or(), .not() and .group() like any other filter. For "has none", use .not().where_exists(...) rather than .not().in_subquery(...): NOT IN matches nothing as soon as the subquery returns a NULL.
When to use a subquery, a JOIN, or a prefetch
Rango does not pick between these for you — you express the intent, and each maps to the cheapest shape for that intent:
| You want… | Use | Why it is the cheap shape |
|---|---|---|
| Keep/drop rows based on whether a related row exists | where_exists (correlated EXISTS) | The DB stops scanning the inner table at the first matching row; no row multiplication, no DISTINCT, no JOIN to clean up |
| Compare a column against a set of values from another query | in_subquery (IN (SELECT …)) | One subquery, values stay bound; avoids loading them into Rust |
| Select columns from a related table alongside the row | select_related (single JOIN) | You need the related columns in the result, so a JOIN is the right tool — a subquery would drop them |
| Load the related rows themselves to iterate them | prefetch_related (one batched IN / = ANY) | One extra query for the whole page, which is what avoids the N+1 of a per-row lookup |
The rule of thumb: a subquery answers "does it exist?" or "is it in this set?" without touching the related rows; a JOIN or prefetch is only for when you actually need the related data in hand. Reaching for a JOIN just to filter (then DISTINCT-ing the duplicates) is the N+1-adjacent anti-pattern where_exists exists to replace.
Ordering, limit, offset
#![allow(unused)] fn main() { User::filter(&pool) .order_by("name") // ASC .order_by("-created_at") // DESC (prefix with -) .limit(20) .offset(40) .all() .await? }
Default limit
By default, .all() returns at most 1000 rows. This is a safety cap, not a pagination boundary: when exactly 1000 rows come back and you set neither .limit(n) nor .unlimited(), Rango logs a tracing WARN naming the table, because rows may have been cut off.
Override with .limit(n) or remove with .unlimited():
#![allow(unused)] fn main() { User::filter(&pool).limit(5000).all().await? // explicit limit User::filter(&pool).unlimited().all().await? // no limit }
The cap applies to the query builder only (all(), values(), select_as::<P>().all()). The plain rango::all::<M>(&pool) function has no cap and returns every row of the table.
Terminal methods
WithRelated<M> derefs to M; call .into_inner() to get the owned M (models can #[derive(Model, Clone, Debug)]).
| Method | Returns |
|---|---|
.all() | Vec<WithRelated<M>> |
.one() | Option<M> (no relation loading) |
.count() | i64 |
.exists() | bool |
.explain() | String — the SQL that would run |
.explain_analyze() | String — the plan with real timings (runs the query) |
Inspecting the SQL
#![allow(unused)] fn main() { let sql = User::filter(&pool) .eq("active", true) .order_by("name") .limit(10) .explain(); println!("{}", sql); // SELECT * FROM "user" WHERE "active" = $1 ORDER BY "name" ASC LIMIT 10 }
A query builder takes &pool or &mut **tx (inside atomic()). .explain_analyze().await? (it takes &mut self, so the builder stays usable afterwards) runs the query under the engine's profiler and returns the plan:
| Backend | Statement | Executes the query |
|---|---|---|
| Postgres | EXPLAIN ANALYZE | yes |
| MySQL 8.0.18+ | EXPLAIN ANALYZE | yes |
| MariaDB | ANALYZE FORMAT=JSON | yes |
| SQLite | EXPLAIN QUERY PLAN (no timings) | no |
The rows are discarded, but the query does run: keep it for debugging, not hot paths.
Aggregations
sum / avg
Return Result<Option<f64>>: None if the table is empty or all values are NULL. The database casts the result to a float (float8 / DOUBLE / REAL), so a FieldDecimal column loses its exactness here.
#![allow(unused)] fn main() { let total = Order::filter(&pool).sum("amount").await?; let average = Order::filter(&pool).avg("score").await?; // With filters let avg_active = User::filter(&pool) .eq("active", true) .avg("score") .await?; }
min / max
Generic over the return type — works for numbers, strings, dates.
Exact decimals
FieldDecimal columns: min::<Decimal> and max::<Decimal> compile on PostgreSQL and MySQL / MariaDB only. On SQLite Decimal cannot be decoded, so they do not compile. sum and avg return f64 on every backend, never an exact decimal.
The portable way to get an exact extreme is to read the row:
#![allow(unused)] fn main() { let priciest = Order::filter(&pool).order_by("-fee").limit(1).one().await?; // Option<Order> let max_fee: Option<Decimal> = priciest.map(|o| o.fee.0); }
For an exact total, read the rows (or values()) and add the Decimals in Rust.
#![allow(unused)] fn main() { // Numeric let lowest: Option<f64> = Order::filter(&pool).min::<f64>("price").await?; let highest: Option<f64> = Order::filter(&pool).max::<f64>("price").await?; // Dates let oldest: Option<chrono::NaiveDate> = Event::filter(&pool).min::<chrono::NaiveDate>("date").await?; let latest: Option<chrono::DateTime<chrono::Utc>> = Post::filter(&pool) .eq("published", true) .max::<chrono::DateTime<chrono::Utc>>("created_at") .await?; // Strings let first: Option<String> = User::filter(&pool).min::<String>("name").await?; }
count
#![allow(unused)] fn main() { let total = User::filter(&pool).count().await?; let active_count = User::filter(&pool) .eq("active", true) .count() .await?; }
exists
#![allow(unused)] fn main() { let has_admins = User::filter(&pool) .eq("role", "admin") .exists() .await?; if has_admins { println!("At least one admin exists"); } }
select_related & prefetch_related
The N+1 problem
Without relation loading, fetching posts and their authors requires N+1 queries:
#![allow(unused)] fn main() { // ❌ N+1 — 1 query for posts + N queries for authors let posts = Post::filter(&pool).all().await?; for post in &posts { let author = get::<_, User>(&pool, &post.author_id.to_sql_value()).await?; // ^ one query per post! } }
Rango solves this with select_related and prefetch_related.
select_related (FK → JOIN)
For direct foreign keys: one query, with a LEFT JOIN per related model:
#![allow(unused)] fn main() { let posts = Post::filter(&pool) .select_related(Post::AUTHOR_ID) .all() .await?; for post in &posts { let author = post.related::<User>(); // &User, zero SQL println!("{} by {}", post.title, author.name); } }
SQL generated:
SELECT "__m".*, "__r0"."id" AS "__r0_id", "__r0"."name" AS "__r0_name", ...
FROM (SELECT * FROM "post" WHERE ... ORDER BY ... LIMIT 1000) AS "__m"
LEFT JOIN "user" AS "__r0" ON "__r0"."id" = "__m"."author_id"
Filters, ordering and LIMIT apply to the posts before the join. A post whose FK is NULL comes back without a related object: .related::<User>() panics on it, so only use select_related on nullable FKs when you know they are set.
prefetch_related (reverse FK → one batched query)
For reverse foreign keys (Comment.post_id → Post), one second batched query:
#![allow(unused)] fn main() { let posts = Post::filter(&pool) .prefetch_related(Comment::POST_ID) .all() .await?; for post in &posts { let comments = post.prefetched::<Comment>(); // &[Comment], zero SQL println!("{} has {} comments", post.title, comments.len()); } }
SQL generated:
-- Query 1
SELECT * FROM "post"
-- Query 2
SELECT * FROM "comment" WHERE "post_id" = ANY($1) -- $1: array of post ids
SQLite and MySQL have no array parameters: they send WHERE "post_id" IN (?, ?, ...) in batches of 900 keys. Every backend runs the main query and the prefetch queries on one connection, and all() returns Vec<WithRelated<M>> everywhere (it derefs to M, so post.title works as before).
Many-to-many
Pass the ManyToMany field constant (#[derive(Model)] generates Article::TAGS for a tags: ManyToMany<Tag> field). One query joins the pivot table:
#![allow(unused)] fn main() { let articles = Article::filter(&pool) .prefetch_related(Article::TAGS) .all() .await?; for article in &articles { let tags = article.prefetched::<Tag>(); // &[Tag], zero SQL } }
SELECT "__p"."article_id" AS "__m2m_key", "__t".*
FROM "tag" AS "__t"
INNER JOIN "article_tag" AS "__p" ON "__t"."id" = "__p"."tag_id"
WHERE "__p"."article_id" = ANY($1)
Combining both
#![allow(unused)] fn main() { let posts = Post::filter(&pool) .select_related(Post::AUTHOR_ID) // FK → JOIN .prefetch_related(Comment::POST_ID) // reverse → batch IN .all() .await?; for post in &posts { let author = post.related::<User>(); let comments = post.prefetched::<Comment>(); } }
Panics
Calling .related::<R>() or .prefetched::<R>() without the corresponding
select_related / prefetch_related call panics with a clear message:
thread panicked: select_related<User> was not loaded;
add .select_related(..) for User to the QueryBuilder chain
Large result sets
Both queries run on the same pooled connection. The keys are bound as one array parameter, so the statement is the same for 1 or 50 000 posts and is prepared once.
prefetch_related should only be used with paginated results.
Loading 10 000 posts and prefetching all their comments will use significant memory.
Always combine with .limit().
values()
Load only specific columns without deserializing a full model.
Returns Vec<HashMap<String, SqlValue>>.
Usage
#![allow(unused)] fn main() { let rows = User::filter(&pool) .eq("active", true) .values(&["id", "email"]) .await?; for row in &rows { if let Some(SqlValue::Text(email)) = row.get("email") { println!("{}", email); } } }
Why use values()?
- Avoid loading heavy columns (e.g. large
TEXTfields) when you only need a few - Build lightweight projections for APIs
- Aggregate data without full model deserialization
SqlValue variants
| Postgres type | SqlValue |
|---|---|
BOOLEAN | SqlValue::Bool(bool) |
INT2/INT4/INT8 | SqlValue::BigInt(i64) |
FLOAT4/FLOAT8 | SqlValue::Double(f64) |
UUID | SqlValue::Uuid(Uuid) |
TEXT/VARCHAR/... | SqlValue::Text(String) |
NUMERIC / DECIMAL | SqlValue::Decimal(Decimal), at the column scale (15.00, not 15); SqlValue::NullDecimal for NULL |
NULL | SqlValue::NullText (or typed null) |
Decimal columns are decoded from the model's schema type, so SQLite, MySQL and MariaDB return the same SqlValue::Decimal as Postgres.
With filters and ordering
All QueryBuilder methods work before .values():
#![allow(unused)] fn main() { let rows = Post::filter(&pool) .eq("published", true) .order_by("-created_at") .limit(10) .values(&["id", "title", "created_at"]) .await?; }
Difference from raw SQL
values() still uses the query builder's WHERE conditions and is safe from SQL injection.
Use raw() only when you need SQL that the query builder can't express.
Partial selects (only / defer)
Select a subset of model columns into a partial struct.
Declare a partial
#[derive(Partial)]
#[partial(model = User)]
struct UserLite {
id: FieldUuid,
email: FieldEmail,
}
Every field must match a model column by name and type. A wrong name or type is a compile error. Partial is in rango::prelude.
Query
let users: Vec<UserLite> = User::filter(&pool)
.eq(User::ACTIVE, true)
.select_as::<UserLite>()
.all()
.await?;
let user: Option<UserLite> = User::filter(&pool)
.eq(User::ACTIVE, true)
.select_as::<UserLite>()
.one()
.await?;
The SELECT lists only the partial's columns, for example:
SELECT "id", "email" FROM "user" ...
Large columns such as bodies and password hashes are never read.
Supported behavior
- Same filters, ordering,
limit/offsetas.all(). - Relations (
select_related,prefetch_related) are not supported withselect_as. - Works on Postgres, SQLite, MySQL.
Why a struct?
Why a struct and not .only(&["id"]) returning the model: a model with missing fields could be saved back with update() and overwrite columns. For untyped maps, .values(&["id", "email"]) already exists (see values).
Pagination
Offset pagination
.limit(n) / .offset(n) are simple, but OFFSET makes the database scan and discard skipped rows. It is slow on deep pages.
let page = User::filter(&pool).limit(100).offset(200).all().await?;
Keyset pagination
.after(col, value) adds col > value and orders by col ASC first (no separate .order_by(col) needed; if you add one on the same column it is not duplicated).
.before(col, value) adds col < value and orders by col DESC first.
Use a unique, indexed column, such as a primary key or unique timestamp. Cost is constant per page.
let mut last = None;
loop {
let mut q = User::filter(&pool).order_by(User::ID).limit(100);
if let Some(id) = last {
q = q.after(User::ID, id);
}
let page = q.all().await?;
let Some(row) = page.last() else { break };
last = Some(row.id.clone());
}
Always call .order_by(User::ID) (the keyset column) on the first page too. .after() only puts its column first on the pages that call it. Without order_by, page 1 uses the model's default ordering, later pages use the keyset column, and rows are silently skipped.
Default limit
When no limit is set, the default cap is 1000 rows, and a WARN is logged if exactly 1000 rows come back. Use .unlimited() to remove it.
let all = User::filter(&pool).unlimited().all().await?;
Raw SQL
Raw SQL is an escape hatch for queries the builder can't express. Always use bound parameters — never interpolate user input.
Table names in the examples are app_user (a model with #[model(table = "app_user")]). Quote reserved names such as user or order: "user" on PostgreSQL and SQLite, `user` on MySQL. On PostgreSQL an unquoted user is the current_user function, so SELECT * FROM user does not read your table.
Imports. The examples call
raw,raw_scalar,raw_execute(and elsewhereinsert,bulk_create, ...) as plain functions. Withuse rango::prelude::*;they are in scope on any backend. At the crate root (rango::raw_execute,rango::bulk_create) they exist only with thepostgresfeature. With onlysqliteormysql, writerango::sqlite::raw_execute/rango::mysql::raw_execute, or use the prelude.
raw() — returns model rows
raw takes the executor and the row type: raw::<_, User>(...), or let the binding infer it with let users: Vec<User> = raw(...).
#![allow(unused)] fn main() { let users = raw::<_, User>( &pool, "SELECT * FROM app_user WHERE role = $1 AND created_at > $2", vec![ SqlValue::Text("admin".into()), SqlValue::DateTime(cutoff_date), ], ).await?; }
raw_scalar() — returns a single value
#![allow(unused)] fn main() { let count: i64 = raw_scalar( &pool, "SELECT COUNT(*) FROM app_user WHERE active = $1", vec![SqlValue::Bool(true)], ).await?; let name: String = raw_scalar( &pool, "SELECT name FROM app_user WHERE id = $1", vec![SqlValue::Uuid(user_id)], ).await?; }
raw_execute() — INSERT / UPDATE / DELETE / DDL
#![allow(unused)] fn main() { let affected = raw_execute( &pool, "UPDATE app_user SET score = score + $1 WHERE active = $2", vec![SqlValue::Int(10), SqlValue::Bool(true)], ).await?; println!("{} users updated", affected); }
Stored procedures and functions
There is no rango::call: a procedure call is one line of SQL, and the raw* functions already bind its arguments.
#![allow(unused)] fn main() { // PostgreSQL procedure: CALL returns the INOUT parameters as one row let total: i32 = raw_scalar(&pool, "CALL compute_total($1, NULL)", vec![SqlValue::Int(order_id)]).await?; // PostgreSQL function returning a set: select from it like a table let users = raw::<_, User>(&pool, "SELECT * FROM active_users($1)", vec![SqlValue::Int(30)]).await?; // MySQL / MariaDB: the procedure's result set comes back like a SELECT let rows = raw::<_, User>(&pool, "CALL active_users(?)", vec![SqlValue::Int(30)]).await?; }
The procedure name is part of the SQL string: write it in the code, never take it from user input. SQLite has no stored procedures.
Create procedures in a migration file, not with raw_execute: MySQL 8.4 refuses CREATE PROCEDURE as a prepared statement, and rango migrate sends migration files as plain text.
⚠️ Security
raw() does not escape your SQL string. Only the bound parameters are safe.
#![allow(unused)] fn main() { // ❌ NEVER DO THIS let sql = format!("SELECT * FROM users WHERE name = '{}'", user_input); raw_execute(&pool, &sql, vec![]).await?; // ✅ ALWAYS use bound parameters raw_execute( &pool, "SELECT * FROM users WHERE name = $1", vec![SqlValue::Text(user_input)], ).await?; }
Placeholders
| Backend | Placeholder |
|---|---|
| PostgreSQL | $1, $2, $3... |
| MySQL / MariaDB | ?, ?, ?... |
| SQLite | ?, ?, ?... |
When to use raw SQL
Use raw() when:
- Complex JOINs across multiple tables
- Window functions (
ROW_NUMBER,RANK, etc.) - Database-specific functions not in the query builder
- Performance-critical queries needing precise SQL
If you find yourself using raw() often, consider opening an issue — it might be a missing feature in the query builder.
CRUD
Insert
#![allow(unused)] fn main() { use rango::prelude::*; let user = User { id: FieldUuid(uuid::Uuid::new_v4()), email: FieldEmail::new("alice@example.com")?, name: FieldVarchar::new("Alice")?, active: FieldBool(true), created_at: FieldDateTime(chrono::Utc::now()), }; let inserted = insert(&pool, user).await?; // Returns the saved model with any DB-generated values }
Get by primary key
#![allow(unused)] fn main() { let user = get::<_, User>(&pool, &SqlValue::Uuid(user_id)).await?; match user { Some(u) => println!("Found: {}", u.email), None => println!("Not found"), } }
Update
#![allow(unused)] fn main() { let mut user = get::<_, User>(&pool, &id).await?.unwrap(); user.name = FieldVarchar::new("Alice Updated")?; let updated = update(&pool, user).await?; }
Delete
#![allow(unused)] fn main() { delete(&pool, &user).await?; }
Delete is idempotent: deleting a row that does not exist (already deleted, or never inserted) is not an error and returns Ok(()). update of a missing row returns Error::NotFound.
get_or_create
Atomic — safe against race conditions:
#![allow(unused)] fn main() { let lookup = vec![("email", SqlValue::Text("alice@example.com".into()))]; let defaults = User { /* ... */ }; let (user, created) = get_or_create(&pool, lookup, defaults).await?; if created { println!("New user created"); } else { println!("Existing user found"); } }
Uses INSERT ... ON CONFLICT DO NOTHING RETURNING * in a single round-trip.
update_or_create
#![allow(unused)] fn main() { let lookup = vec![("email", SqlValue::Text("alice@example.com".into()))]; let values = User { /* ... */ }; let user = update_or_create(&pool, lookup, values).await?; }
Updates the row matching lookup (update hooks fire; the whole row is overwritten from the values of values, only the primary key is kept, so fields you did not mean to change are reset too) or inserts values (create hooks fire). It takes a pool or a transaction (&mut **tx) on every backend. The lookup and the write are two statements: wrap the call in atomic() to make them one unit.
all()
Fetch all rows (subject to the default limit):
#![allow(unused)] fn main() { let users = all::<_, User>(&pool).await?; }
Bulk Operations
Bulk operations insert or update multiple rows efficiently in a single query.
Bulk operations do not run model hooks (before_save, after_create, ...).
bulk_create
Insert multiple rows in one query. Automatically chunks to stay within database parameter limits.
#![allow(unused)] fn main() { let users: Vec<User> = (0..1000) .map(|i| User { id: FieldUuid(uuid::Uuid::new_v4()), email: FieldEmail::new(format!("user{}@example.com", i))?, name: FieldVarchar::new(format!("User {}", i))?, active: FieldBool(true), created_at: FieldDateTime(chrono::Utc::now()), }) .collect(); let inserted = bulk_create(&pool, &users).await?; println!("Inserted {} rows", inserted); }
On PostgreSQL, generates one INSERT INTO ... SELECT * FROM UNNEST($1::type[], $2::type[], ...) with one array per column, whatever the row count (falls back to multi-row VALUES for JSON columns). On SQLite and MySQL, generates one INSERT INTO ... VALUES (...), (...), ... per chunk.
bulk_update
Update specific fields on multiple rows with one statement per chunk.
#![allow(unused)] fn main() { // Deactivate all users in the list for user in &mut users { user.active = FieldBool(false); } let updated = bulk_update(&pool, &users, &["active"]).await?; println!("Updated {} rows", updated); }
On PostgreSQL this is UPDATE ... FROM (VALUES ...) AS v WHERE id = v.id. SQLite uses WITH v(id, ..) AS (VALUES ..) UPDATE ... FROM v WHERE t.id = v.id, and MySQL / MariaDB use UPDATE ... SET col = CASE id WHEN ? THEN ? ... END WHERE id IN (...) (a derived table is typed differently by the two engines). That is one statement per chunk on every backend; several chunks run in one transaction. MySQL chunks are capped at 100 rows because a CASE is scanned linearly for each row. Unknown columns and the primary key are rejected with Error::Invalid before any SQL runs.
bulk_upsert
Insert or update on conflict.
#![allow(unused)] fn main() { let updated = bulk_upsert(&pool, &users, &["email"]).await?; // conflict_on: columns used to detect duplicates (must have a UNIQUE constraint) }
The return value is the number of rows the database reports as affected, summed over chunks. On PostgreSQL and SQLite it is one per inserted or updated row. On MySQL / MariaDB it is 1 per inserted row and 2 per updated row (and 0 for a row left unchanged), so it is not the number of models you passed.
Uses INSERT ... ON CONFLICT (email) DO UPDATE SET ... on PostgreSQL and SQLite, and INSERT ... ON DUPLICATE KEY UPDATE on MySQL. MySQL fires on a duplicate of any unique index and cannot target one, so Rango checks the model first: if it has a unique key other than conflict_on (a #[field(unique)] column or a non-partial UniqueConstraint, compared as an unordered column set), bulk_upsert returns Error::Invalid before any SQL runs. The primary key is exempt (a PK collision is the same row), but upserting on the primary key is rejected when another unique key exists. On MySQL the conflict_on columns are not overwritten.
Chunking
PostgreSQL and MySQL have a limit of 65,535 bind parameters per query. SQLite (3.32+, bundled with sqlx) allows 32,766; Rango chunks at 32,000.
Rango automatically splits large batches into chunks — transparent to the caller. Postgres bulk_create needs no chunking: UNNEST binds one array per column.
Atomicity
Every bulk operation is all-or-nothing. When it needs several statements (chunked VALUES, bulk_update, bulk_upsert), Rango runs them in one transaction, so a failure in a later chunk (a duplicate, a constraint violation) leaves no earlier rows behind. Called inside your own transaction, it uses a savepoint.
Inside transactions
bulk_create, bulk_update and bulk_upsert take a pool or a transaction on all three backends (&mut **tx is the connection behind the transaction).
#![allow(unused)] fn main() { atomic(&pool, async |tx| { bulk_create(&mut **tx, &users).await?; bulk_create(&mut **tx, &posts).await?; bulk_update(&mut **tx, &users, &["active"]).await?; Ok(()) }).await?; }
Transactions
atomic()
Wrap multiple operations in a transaction. Automatically commits on success, rolls back on error.
#![allow(unused)] fn main() { atomic(&pool, async |tx| { let user = insert(&mut **tx, new_user).await?; let profile = insert(&mut **tx, Profile { user_id: ForeignKey::new(user.id.0), ..profile }).await?; Ok((user, profile)) }) .await?; }
The body is an async closure (async |tx| { ... }, Rust 1.85+). The future it returns is Send, so tokio::spawn(async move { atomic(&pool, async |tx| { ... }).await }) works.
If any operation returns Err, the entire transaction is rolled back. To abort on a business rule, return Err(Error::abort("insufficient funds")).
Retrying on conflict
Use atomic_retry() for SERIALIZABLE / REPEATABLE READ workloads and deadlocks. It retries only Error::Conflict (Postgres: serialization failure 40001, deadlock 40P01; MySQL/MariaDB: deadlock 1213 and lock wait timeout 1205); other errors are returned immediately.
#![allow(unused)] fn main() { atomic_retry(&pool, 3, async |tx| { raw_execute(&mut **tx, "SET TRANSACTION ISOLATION LEVEL SERIALIZABLE", vec![]).await?; // ... Ok(()) }) .await?; }
The future of atomic_retry is Send too, so tokio::spawn(async move { atomic_retry(&pool, 3, async |tx| { ... }).await }) works. For that, the closure must be Clone (it is cloned for each attempt): an async closure is Clone when everything it captures is, so capture shared state by reference (&AtomicU32, &Arc<..>), not as a &mut or by moving a non-Clone value.
The closure can run several times, so keep it free of side effects outside the transaction (no emails, no HTTP calls). Postgres and MySQL/MariaDB (atomic() works on every backend, SQLite has no equivalent). On MySQL a lock wait timeout is a Conflict (so it is retried), while on Postgres lock_timeout stays Error::Timeout.
Manual transactions
For more control, use pool.begin() directly:
#![allow(unused)] fn main() { let mut tx = pool.begin().await?; insert(&mut *tx, user).await?; insert(&mut *tx, profile).await?; tx.commit().await?; // or tx.rollback().await? to cancel }
Queries, M2M and bulk inside a transaction
filter, the M2M operations and the bulk operations take a pool or a transaction, on every backend. Inside atomic() pass &mut **tx:
#![allow(unused)] fn main() { atomic(&pool, async |tx| { bulk_create(&mut **tx, &users).await?; let n = User::filter(&mut **tx).eq("active", true).count().await?; // sees the writes above M2M::<Article, Tag>::add(&mut **tx, &article, &tag).await?; Ok(n) }) .await?; }
If the closure returns Err, none of it is kept.
Nesting
Transactions can be nested — inner failures roll back to the outer savepoint. This is handled automatically by the database driver.
Hooks inside transactions
Model hooks run inside the same transaction as the operation.
If a hook returns Err, the whole transaction rolls back — including any operations that ran before the hook.
get_or_create is atomic
get_or_create uses INSERT ... ON CONFLICT DO NOTHING RETURNING * (SQLite, MySQL: the equivalent for the engine) — it's atomic without wrapping in atomic(). Safe against race conditions. It takes a pool or a transaction.
Errors
Every operation returns rango::Result<T>, with a typed rango::Error. Match on variants instead of parsing strings:
#![allow(unused)] fn main() { match insert(&pool, user).await { Ok(user) => Ok(user), Err(Error::UniqueViolation { .. }) => Err(ApiError::Conflict("email already taken")), Err(e) if e.is_retryable() => retry(), Err(e) => Err(e.into()), } }
| Variant | When |
|---|---|
NotFound | A query expected a row and found none |
TableMissing { table } | The table does not exist: run rango makemigrations and rango migrate |
UniqueViolation / ForeignKeyViolation / NotNullViolation / CheckViolation | Constraint violated; constraint holds its name when the backend reports it (on SQLite, the failing columns, e.g. user.email; on MySQL and MariaDB, parsed from the server message, without the table. prefix MySQL 8 adds to unique keys; never the value or the SQL; NotNullViolation carries none) |
Conflict | Serialization failure or deadlock (40001 / 40P01); is_retryable() is true |
Timeout | Pool acquire, statement_timeout, lock_timeout, or SQLite busy timeout |
Connection | IO, TLS, or closed pool |
Decode | A row could not be decoded into the model |
Invalid | Invalid input or configuration; an unknown column name suggests the closest one (did you mean email?) |
Database { code, message } | Any other database error (code is the SQLSTATE on Postgres) |
Aborted | Your code returned Error::abort(..) inside atomic(); the transaction was rolled back |
Errors never contain SQL text or bound values. The Database message is the database's own message, which can quote an offending value (e.g. invalid input syntax). Log it, but don't send it to clients as-is.
rango::Error implements std::error::Error + Send + Sync, so ? converts it into anyhow::Error or your own error type.
Tracing
Every database operation (CRUD, query builder executions, aggregates, bulk ops, many-to-many ops, raw SQL) emits a tracing span named db.query.
Span fields
| Field | Value |
|---|---|
db.system | postgresql, sqlite, or mysql |
db.operation | SQL operation: SELECT, INSERT, UPDATE, DELETE, ... |
db.sql.table | Model table (absent on raw SQL) |
db.rows | Rows returned, filled after the query |
otel.kind | "client" |
sqlx events
sqlx logs one event per statement on target sqlx::query, at DEBUG level. Fields:
summary: the SQL text when it is short, otherwise a truncated form ending in…db.statement: the full SQL text, filled only whensummaryis truncated; an empty string for short statements (their SQL is insummary)rows_affectedrows_returnedelapsed
Bound values are never logged.
Statements slower than the threshold are logged on the same target at WARN ("slow statement"), with the same fields plus slow_threshold.
Slow query log
- Postgres:
DatabaseConfig::slow_query_threshold_ms(default500) logs statements slower than the threshold atWARN. - SQLite and MySQL: fixed
500ms.
Setup
The per-statement events are DEBUG, so a default info filter hides them. To see every statement, enable sqlx::query=debug; to keep only the slow-statement warnings, use sqlx::query=warn (it hides all the other statements).
tracing_subscriber::fmt()
.with_env_filter("info,sqlx::query=debug") // every statement
// .with_env_filter("info,sqlx::query=warn") // slow statements only
.init();
OpenTelemetry
To export to OpenTelemetry, add tracing-opentelemetry to your app. Rango's span fields already follow the conventions. This is the app's choice; Rango does not depend on OpenTelemetry.
PostgreSQL
PostgreSQL is the primary Rango backend with full feature support.
Setup
# Cargo.toml
[dependencies]
rango-orm = "0.2"
# rango.toml
[database]
backend = "postgres"
export DATABASE_URL=postgres://user:password@localhost:5432/mydb
Connecting
#![allow(unused)] fn main() { use rango::prelude::*; let config = DatabaseConfig::from_env()?; let pool = connect_postgres(&config).await?; }
Postgres-specific features
Import PgQueryExt to unlock Postgres-only filter methods:
#![allow(unused)] fn main() { use rango::prelude::*; // PgQueryExt is included // Full-text search let articles = Article::filter(&pool) .fts("content", "rust async orm") .all() .await?; // JSONB containment let posts = Post::filter(&pool) .json_contains("metadata", serde_json::json!({"status": "published"})) .all() .await?; // JSONB key existence let posts = Post::filter(&pool) .json_has_key("metadata", "featured_image") .all() .await?; // JSONB field equality let posts = Post::filter(&pool) .json_field_eq("metadata", "status", "draft") .all() .await?; // Array containment let users = User::filter(&pool) .array_contains("roles", vec!["admin".into(), "moderator".into()]) .all() .await?; }
Standalone helpers
#![allow(unused)] fn main() { use rango::Pg; // Full-text search standalone let results = Pg::fts::<Article>(&pool, "content", "query").await?; // Get a JSONB field value let status = Pg::json_get::<Post>(&pool, &post_id, "metadata", "status").await?; }
Supported types
All Rango field types are fully supported on PostgreSQL with their native SQL types. See Field Types for the complete mapping.
SQLite
SQLite is the default Rango backend — ideal for development, testing, and small applications.
Setup
# Cargo.toml
[dependencies]
rango-orm = { version = "0.2", default-features = false, features = ["sqlite"] }
# rango.toml
[database]
backend = "sqlite"
export DATABASE_URL=sqlite://db.sqlite3
# In-memory (for tests):
export DATABASE_URL=sqlite::memory:
Connecting
#![allow(unused)] fn main() { use rango::prelude::*; let pool = connect_sqlite("sqlite://db.sqlite3").await?; // or let pool = connect_sqlite("sqlite::memory:").await?; }
SQLite-specific features
Import SqliteQueryExt to unlock SQLite-only filter methods:
#![allow(unused)] fn main() { use rango::prelude::*; // SqliteQueryExt is included // json_extract — filter by JSON field value Post::filter(&pool) .json_extract_eq("metadata", "$.status", "published") .all() .await? // json_extract with LIKE Post::filter(&pool) .json_extract_like("metadata", "$.tags", "%rust%") .all() .await? // Check if JSON key exists Post::filter(&pool) .json_extract_exists("metadata", "$.featured_image") .all() .await? // FTS5 full-text search (requires a virtual FTS5 table) Article::filter(&pool) .fts5("articles_fts", "rust async") .all() .await? }
Type mapping
SQLite has fewer native types than Postgres. Rango maps transparently:
| Rango type | SQLite column |
|---|---|
FieldUuid | TEXT (UUID string) |
FieldBool | INTEGER (0/1) |
FieldDateTime | TEXT (ISO 8601) |
FieldJson | TEXT (serialized JSON) |
FieldVarchar | TEXT |
Limitations vs Postgres
| Feature | Postgres | SQLite |
|---|---|---|
| Native UUID | ✅ | ❌ (stored as TEXT) |
| JSONB operators | ✅ | ❌ (use json_extract) |
ALTER COLUMN TYPE | ✅ | ❌ |
SET/DROP NOT NULL | ✅ | ❌ |
| Full-text search | tsvector | FTS5 (virtual table) |
RETURNING * | ✅ | ✅ (SQLite ≥ 3.35) |
Errors
On SQLite a unique violation, including one on a partial unique index, reports the column names (user.email), not the index name, and a foreign key violation carries no constraint name. See Errors.
Testing with in-memory SQLite
Create the schema from the migration files rango makemigrations generated. Each file is plain SQL, and raw_execute runs a string with several statements on SQLite (give it no parameters).
#![allow(unused)] fn main() { use rango::prelude::*; const MIGRATIONS: &[&str] = &[ include_str!("../migrations/0001_user.sql"), include_str!("../migrations/0002_post.sql"), ]; async fn test_pool() -> SqlitePool { let pool = connect_sqlite("sqlite::memory:").await.unwrap(); for sql in MIGRATIONS { raw_execute(&pool, sql, vec![]).await.unwrap(); } pool } #[tokio::test] async fn my_test() { let pool = test_pool().await; // Test your logic... } }
List the files in order. A new migration means a new line here. No Docker, no external process: fast and isolated.
Production on PostgreSQL/MySQL, tests on SQLite
rango.toml fixes one backend, and makemigrations writes SQL for that backend only. A migration folder made for PostgreSQL does not run on SQLite (types, ALTER, index syntax differ). The supported setup is a second set of migrations:
- Keep your main
rango.toml(backend = "postgres") andmigrations/. - Add a folder, for example
tests-sqlite/, with its ownrango.tomlthat saysbackend = "sqlite". - From that folder, generate the SQLite files from the same models:
cd tests-sqlite && rango makemigrations ../src --output migrations. Commit them, and re-run after every model change. - Load those files in the test helper above (
include_str!("../tests-sqlite/migrations/0001_user.sql")).
Limits:
- You maintain two migration sets.
rango makemigrations --checkin each folder tells you when one is stale. - SQLite is not PostgreSQL. Tests do not see native UUID,
JSONBoperators,DECIMALscale,PgQueryExtmethods, or the locking and error details of the real server. - Decimal aggregates, partial unique indexes and constraint names behave differently (see above and Field types).
- Run a final test pass against the production engine before release.
MySQL / MariaDB
One backend, rango-orm-mysql, covers MySQL 8.0+ and MariaDB 10.6+. It sticks to the subset both servers share, so the same models, queries and migration files work on either.
Setup
# Cargo.toml
[dependencies]
rango-orm = { version = "0.2", default-features = false, features = ["mysql"] }
# rango.toml
[database]
backend = "mysql" # "mariadb" is accepted too
export DATABASE_URL=mysql://user:password@localhost:3306/mydb
# mariadb:// is accepted as an alias of mysql://
Connecting
#![allow(unused)] fn main() { use rango::prelude::*; let pool = connect_mysql(&std::env::var("DATABASE_URL")?).await?; }
Every connection uses utf8mb4, runs in UTC (DATETIME has no time zone) and in strict SQL mode, so invalid values are rejected instead of being silently truncated.
TLS: connections to a remote host use ssl-mode=REQUIRED unless the URL sets its own ssl-mode, so they never fall back to cleartext. Loopback and Unix socket connections are left as is. REQUIRED encrypts but does not check the server certificate: use ssl-mode=VERIFY_IDENTITY in production.
Type mapping
| Rango type | MySQL / MariaDB column |
|---|---|
FieldUuid | BINARY(16) |
FieldBool | BOOLEAN (TINYINT(1)) |
FieldInt / FieldBigInt / FieldSmallInt | INT / BIGINT / SMALLINT |
FieldAutoInt / FieldAutoBigInt | INT / BIGINT AUTO_INCREMENT (id read back with LAST_INSERT_ID()) |
FieldDecimal<P, S> | DECIMAL(P, S) |
FieldVarchar<_, N> | VARCHAR(N) |
FieldText | LONGTEXT |
FieldBytes | LONGBLOB |
FieldJson | JSON (a LONGTEXT with a json_valid check on MariaDB) |
FieldDate / FieldTime / FieldDateTime | DATE / TIME(6) / DATETIME(6) |
Tables are created with ENGINE=InnoDB DEFAULT CHARSET=utf8mb4.
Differences from Postgres
| Feature | Postgres | MySQL / MariaDB |
|---|---|---|
RETURNING * | ✅ | ❌: insert/update return the model you passed (every column is bound from it) |
| Transactional DDL | ✅ | ❌: a migration that fails halfway leaves its earlier statements applied |
Partial unique constraint (.condition(...)) | CREATE UNIQUE INDEX … WHERE | skipped with a comment in the migration; enforce it in application code |
get_or_create | INSERT … ON CONFLICT DO NOTHING RETURNING * | plain INSERT, then a SELECT on unique violation |
| Sub-microsecond timestamps | ✅ | truncated to microseconds |
Migrations
rango makemigrations writes backtick-quoted SQL that also runs as-is in the mysql / mariadb client. Unique constraints, indexes and foreign keys are named ({table}_{col}_unique, {table}_{col}_idx, {table}_{col}_fkey) so later migrations can drop them. Type and nullability changes use MODIFY COLUMN.
MySQL caps identifiers at 64 characters. Constraint and index names that would exceed it are cut and suffixed with a stable hash. Table names are not shortened: if a table or M2M pivot name ({table}_{other_table}) is too long, makemigrations stops with an error. Use a shorter #[model(table = "...")] or model name.
rango migrate takes a GET_LOCK('rango_migrate') advisory lock, so two deploys cannot apply migrations at the same time.
Export / import
rango export writes BINARY(16) values as UUID strings and JSON columns as JSON (MariaDB included). rango import runs in one transaction with foreign key checks off for that session, so tables can appear in any order and a failed import leaves nothing behind. It converts UUID strings back to 16 bytes. Rows that already exist are skipped, or overwritten with --replace.
Local testing
just db-mysql # MySQL 8.4 on :3307, MariaDB 11.4 on :3308 (Docker)
just test-mysql # backend + CLI e2e tests against both
Backend-specific Features
Some database features are not portable across backends. Rango exposes them via backend-specific traits rather than hiding or faking portability.
Philosophy
If a feature doesn't exist on a backend, it's simply not available — no silent fallbacks, no emulation that changes semantics.
#![allow(unused)] fn main() { // This compiles and runs only with the postgres feature use rango::PgQueryExt; Post::filter(&pool) .json_contains("metadata", json!({"status": "published"})) .all() .await? }
If you migrate from Postgres to SQLite, the compiler tells you exactly where backend-specific code is used.
PgQueryExt (Postgres)
| Method | SQL | Description |
|---|---|---|
.fts(col, query) | to_tsvector(...) @@ plainto_tsquery(...) | Full-text search |
.fts_lang(col, query, lang) | Same with custom language | FTS with language config |
.json_contains(col, val) | col @> val::jsonb | JSONB containment |
.json_has_key(col, key) | col ? key | JSONB key existence |
.json_field_eq(col, key, val) | col->>'key' = val | JSONB field equality |
.array_contains(col, vals) | col @> ARRAY[...] | Array containment |
SqliteQueryExt (SQLite)
| Method | SQL | Description |
|---|---|---|
.json_extract_eq(col, path, val) | json_extract(col, path) = ? | JSON field equality |
.json_extract_like(col, path, pat) | json_extract(col, path) LIKE ? | JSON field LIKE |
.json_extract_exists(col, path) | json_extract(col, path) IS NOT NULL | JSON key existence |
.fts5(table, query) | rowid IN (SELECT rowid FROM fts WHERE fts MATCH ?) | FTS5 search |
Adding to the prelude
Each trait is available via use rango::prelude::* when its backend is enabled, whichever backend supplies the prelude's ops (see "Several backends at once" in Installation).
Future extensions
Planned backend-specific features:
PgQueryExt: window functions,DISTINCT ON, advisory locksSqliteQueryExt:json_each(),json_group_array()MySqlQueryExt: MySQL / MariaDB JSON functions
rango init
Initializes a new Rango project by generating rango.toml and the migrations/ directory.
Usage
rango init # SQLite backend (default)
rango init --backend postgres # PostgreSQL backend
rango init --backend sqlite # explicit SQLite
rango init --backend mysql # MySQL (or `mariadb`)
Generated files
rango.toml:
[database]
backend = "sqlite"
# url = "sqlite://db.sqlite3"
# Tip: set DATABASE_URL env var instead of storing credentials here.
[models]
src = "src"
[migrations]
dir = "migrations"
migrations/: Empty directory, ready for generated migration files.
Next steps
1. Define your models with #[derive(Model)]
2. Run `rango makemigrations` to generate SQL migrations
3. Run `rango migrate` to apply them
Notes
rango initfails ifrango.tomlalready exists- The
urlfield is commented out by default — useDATABASE_URLenv var for credentials - The backend field (
sqlite,postgres,mysqlormariadb) determines the SQL dialect formakemigrations
rango makemigrations
See Migrations → makemigrations for the full reference.
Quick reference
rango makemigrations # generate migrations
rango makemigrations --dry-run # preview SQL without writing
rango makemigrations --check # CI guard: exit 1 if pending
rango makemigrations --yes # non-interactive: confirm renames and drops
rango makemigrations src/models # custom source directory (default: [models] src in rango.toml)
rango migrate
See Migrations → migrate for the full reference.
Quick reference
DATABASE_URL=... rango migrate # apply pending migrations
DATABASE_URL=... rango migrate --migrations path # custom directory
rango export
Exports data from the database to JSON or CSV.
Usage
# Export all tables to stdout (JSON)
rango export
# Export a specific table
rango export --table users
# Export to a file
rango export --table users --output users.json
# CSV format
rango export --table users --format csv --output users.csv
# Custom database URL
rango export --table users --database-url postgres://...
Options
| Option | Default | Description |
|---|---|---|
--table | all tables | Table to export |
--format | json | Output format: json or csv |
--output | stdout | Output file path |
--database-url | DATABASE_URL | Database connection URL |
JSON format
Single table:
[
{ "id": "...", "email": "alice@example.com", "active": true },
{ "id": "...", "email": "bob@example.com", "active": false }
]
Multiple tables (no --table specified):
[
{ "__table": "user", "id": "...", "email": "alice@example.com" },
{ "__table": "post", "id": "...", "title": "Hello" }
]
The __table field lets rango import route rows to the right table automatically.
CSV format
id,email,active
abc-123,alice@example.com,true
def-456,bob@example.com,false
Cross-backend export
Export from Postgres, import into SQLite:
DATABASE_URL=postgres://... rango export --output data.json
DATABASE_URL=sqlite://dev.db rango import data.json
rango import
Imports data from JSON or CSV into the database.
Usage
# Import from JSON (format auto-detected from extension)
rango import data.json
# Import into a specific table
rango import users.json --table user
# Import CSV
rango import users.csv --table user
# Replace existing rows on conflict
rango import fixtures.json --replace
# Explicit format
rango import data --format json --table users
Options
| Option | Default | Description |
|---|---|---|
--table | from __table field | Target table. Required for a single-table export (rango export --table x) and for CSV, which carry no __table; without it import stops with an error asking for it. |
--format | auto-detected | json or csv |
--replace | skip | Replace existing rows on conflict |
--database-url | DATABASE_URL | Database connection URL |
Conflict behavior
| Mode | SQL | Use case |
|---|---|---|
| Default (skip) | INSERT OR IGNORE / ON CONFLICT DO NOTHING | Load fixtures without overwriting |
--replace | INSERT OR REPLACE / ON CONFLICT DO UPDATE | Sync/upsert data |
JSON format
[
{ "id": "abc-123", "email": "alice@example.com", "active": true },
{ "id": "def-456", "email": "bob@example.com", "active": false }
]
With __table (multi-table import without --table):
[
{ "__table": "user", "id": "...", "email": "alice@example.com" },
{ "__table": "post", "id": "...", "title": "Hello" }
]
Fixtures / loaddata
rango import is Rango's equivalent of Django's loaddata. Use it to seed development databases:
# Export production data (sanitized)
DATABASE_URL=$PROD_URL rango export --table user --output fixtures/users.json
# Load into dev
DATABASE_URL=sqlite://dev.db rango import fixtures/users.json
rango dump / restore / backup
One command set, with the same behaviour on PostgreSQL, SQLite, and MySQL/MariaDB. No pg_dump/mysqldump is needed.
Format
Dumps use "Rango JSON", a backend-independent JSON object with:
format: "rango-dump"version: 2(version 1 files are still read)rango_versionbackendcreated_atschema: the latest migration snapshot, ornulltables:[{ "name": ..., "rows": [ {column: value, ...} ] }]
A dump from one backend can be restored on another.
dump
rango dump
| Option | Default | Description |
|---|---|---|
--schema-only | — | Schema snapshot, no rows. Conflicts with --data-only. |
--data-only | — | Rows, no schema. |
-t, --table <name> | all tables | Table to dump. Repeatable. |
-o, --output <file> | stdout | Output file. Written with 0600 permissions. |
-m, --migrations <dir> | migrations / [migrations] dir from rango.toml | Where the schema snapshot is read. |
-d, --database-url | DATABASE_URL | Database URL. |
--schema-only prints the number of tables in the schema. The backend comes from the URL scheme, not rango.toml.
The _rango_migrations bookkeeping table is not dumped as a table (unless named with --table); its migration names go in a separate migrations field. The schema part needs a snapshot; run rango makemigrations first.
restore
rango restore <file>
Accepts plain JSON or gzip, detected automatically. Rejects files that are not Rango dumps.
File contents are treated as untrusted:
- Table and column names are checked against the dump's schema and quoted.
- Values are bound parameters.
- Decompressed size is capped at 8 GiB against gzip bombs.
Restore creates missing tables from the schema, then inserts rows, in one transaction. When it creates the schema it also recreates _rango_migrations with the dumped migration names, so rango migrate on the restored database has nothing to replay. A dump made by an older rango has no migrations field: restore warns and records nothing, so check rango migrate before running it. On any error, such as a constraint violation or a row conflicting with existing data, nothing is restored. On MySQL/MariaDB, DDL auto-commits, so tables created before a failure stay empty.
Restore works whether the target is empty or was just prepared by rango migrate: migration names already recorded are skipped, while any other conflicting row still fails the restore. The backend is taken from the -d URL scheme (postgres://, mysql://, sqlite://), not from rango.toml.
The schema is rebuilt from the dump's snapshot, not by replaying migration files, so migrations that hold hand-written SQL (stored procedures, views, triggers, data changes) are recorded as applied without being run. Restore prints a warning listing the recorded migrations; re-run the custom ones by hand from your migrations/ directory.
Foreign keys are checked at the end of the transaction, so rows can come in any order, including self-references and FK cycles. On Postgres, a cycle where every FK is NOT NULL needs the cycle's constraints to be DEFERRABLE; rango makemigrations creates them that way, but databases migrated before 0.1.1 keep non-deferrable constraints.
backup
rango backup
Creates a full dump, gzip-compressed, written to backups/rango-backup-YYYYMMDD-HHMMSS.json.gz (UTC) with 0600 permissions.
| Option | Default | Description |
|---|---|---|
--dir <dir> | backups | Backup directory. Created if missing. |
-d, --database-url | DATABASE_URL | Database URL. |
Restore a backup:
rango restore backups/rango-backup-20261106-142500.json.gz
Decimal values are written as JSON strings ("0.50"), so no digit is lost. Binary columns are base64 strings (dumps of version 1 used \\x hex and are still read).
Dumps contain all your data. Keep them out of git and treat them as secrets.
SQL Injection
Safe by default
Every Rango query builder operation uses prepared statements with bound parameters. Values are never interpolated into SQL strings.
#![allow(unused)] fn main() { // Safe — email is bound as $1, never interpolated User::filter(&pool) .eq("email", user_input) .all() .await?; // → SELECT * FROM "user" WHERE "email" = $1 }
The database driver handles all escaping. SQL injection via the query builder is impossible.
Raw SQL is your responsibility
When using raw(), raw_scalar(), or raw_execute(), you write the SQL yourself.
Rango cannot protect you from injection in the SQL string itself.
#![allow(unused)] fn main() { // ❌ DANGEROUS let sql = format!("SELECT * FROM users WHERE name = '{}'", user_input); raw_execute(&pool, &sql, vec![]).await?; // ✅ SAFE — always use bound parameters raw_execute( &pool, "SELECT * FROM users WHERE name = $1", vec![SqlValue::Text(user_input)], ).await?; }
Column names
Prefer typed columns (User::EMAIL): they are checked at compile time. A column name passed as a string (.eq("email", ...), .order_by("created_at")) is checked against the model's schema before any SQL runs, an unknown name returns Error::Invalid, and the name is always quoted with quote_ident. A hostile string cannot inject SQL.
It can still pick any column of the model. If a request chooses a sort or filter column (?sort=...), map it to an allowed list first, so a client cannot filter or sort on a column it should not see (a password hash, an internal flag).
#![allow(unused)] fn main() { let col = match params.sort.as_str() { "name" => User::NAME.asc(), "created" => User::CREATED_AT.desc(), _ => User::CREATED_AT.desc(), }; User::filter(&pool).order_by(col) }
Summary
| Path | Safe? |
|---|---|
Query builder (.eq, .gt, etc.) | ✅ Always safe |
raw() with bound params | ✅ Safe |
raw() with string interpolation | ❌ Never do this |
| String column names | ✅ Validated against the schema and quoted; restrict user-chosen columns to an allowed list |
Every claim on this page and the test behind it: Security requirements.
Credentials
Never commit credentials
The url field in rango.toml is optional and intended for local development only.
Do not store production credentials in rango.toml — it will likely end up in version control.
# rango.toml — safe to commit
[database]
backend = "postgres"
# url is read from DATABASE_URL env var
Use environment variables
# .env (add to .gitignore)
DATABASE_URL=postgres://user:password@prod-host:5432/mydb
# Or export directly
export DATABASE_URL=postgres://user:password@prod-host:5432/mydb
Rango reads DATABASE_URL automatically for rango migrate and other CLI commands.
TLS
Connections to a remote host are encrypted by default: Rango adds sslmode=require (PostgreSQL) or ssl-mode=REQUIRED (MySQL / MariaDB) when the URL sets no mode. Loopback hosts are left as is. An explicit mode in the URL always wins.
require encrypts but does not check the server certificate. In production, set the strict mode in the URL:
DATABASE_URL=postgres://user:password@db.example.com:5432/mydb?sslmode=verify-full
DATABASE_URL=mysql://user:password@db.example.com:3306/mydb?ssl-mode=VERIFY_IDENTITY
Least privilege
Give the application a role that can only read and write rows, and run migrations with a
separate role that owns the schema. rango migrate reads MIGRATE_DATABASE_URL first.
Roles are cluster-global, so CREATE ROLE fails if the role already exists (create it once, or guard it with a DO block that checks pg_roles). Schema grants are per database: connect to the target database before running the grants.
-- Postgres: run as a superuser, connected to the target database
CREATE ROLE app_migrate LOGIN PASSWORD '...';
CREATE ROLE app LOGIN PASSWORD '...';
GRANT CONNECT ON DATABASE mydb TO app_migrate, app;
GRANT CREATE, USAGE ON SCHEMA public TO app_migrate;
GRANT USAGE ON SCHEMA public TO app;
ALTER DEFAULT PRIVILEGES FOR ROLE app_migrate IN SCHEMA public
GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO app;
ALTER DEFAULT PRIVILEGES FOR ROLE app_migrate IN SCHEMA public
GRANT USAGE, SELECT ON SEQUENCES TO app;
MIGRATE_DATABASE_URL=postgres://app_migrate:...@host/db rango migrate
DATABASE_URL=postgres://app:...@host/db ./my-app
rango dump and rango backup must read every table, including _rango_migrations. The DML-only app role is not enough: run them with the migration role (or another role that can read everything).
In production
Use your platform's secret management:
# Docker / Compose
environment:
- DATABASE_URL=postgres://...
# Kubernetes
env:
- name: DATABASE_URL
valueFrom:
secretKeyRef:
name: db-secret
key: url
# systemd
Environment=DATABASE_URL=postgres://...
For tests
Use a separate test database:
# .env.test
DATABASE_URL=postgres://postgres:postgres@localhost:5432/myapp_test
# or
DATABASE_URL=sqlite::memory:
Checklist
-
rango.tomldoes not contain production credentials -
.envfiles are in.gitignore - Test database is separate from production
- Production URL is injected via environment variable
Every claim on this page and the test behind it: Security requirements.
How Rango is built and verified
How the code is written
Rango is written with AI assistance: AI coding agents write most of the code. The maintainer sets the requirements below, reviews every change and makes the decisions. An AI-written change goes through exactly the same checks as a hand-written one.
So you do not have to trust the process: every check on this page can be re-run from a clone of the repository.
Requirements
Each requirement names what enforces it. "Review" means the maintainer checks it by hand; everything else fails a build or a test.
Architecture
- Every database operation is async; nothing blocks the runtime. Enforced by: the public API (every operation returns a future), review.
- The core crate (
rango-orm-core) has no database dependency; each backend is its own crate. Enforced by: the crate manifests (cargo tree -p rango-orm-coreshows no sqlx). - SQL generation is shared, not copied per backend: one query builder in core, one
Dialectper database. Enforced by: review, unit tests on the generated SQL. - Layers stay separate: schema types and SQL generation without IO, execution in the backends, the CLI on top. Enforced by: crate boundaries.
Security
The full list, with the test behind each claim and what Rango does not protect, is in Security requirements. In short:
- Values are always bound parameters, never interpolated into SQL.
- Identifiers are always quoted through one function,
quote_ident. - Operators and connectors are Rust enums, never strings from input.
FieldPasswordstores an Argon2id hash and never prints or serializes the secret.- Remote PostgreSQL and MySQL connections require TLS unless the URL says otherwise.
#![forbid(unsafe_code)]in every crate;cargo auditandcargo denyin CI.
Performance
- No N+1 by construction:
select_relatedis one JOIN,prefetch_relatedone query per relation. Enforced by: integration tests, query counts in the blind tests. RETURNINGinstead of a re-fetch where the database supports it; bulk operations are one statement per chunk. Enforced by: integration tests, benchmarks.- Every foreign key and many-to-many pivot is indexed. Enforced by: migration tests on the generated SQL.
- No regression goes unnoticed: benchmarks are compared A/B with the previous version before a release, and against sqlx, SeaORM and Diesel (benchmarks/README.md).
API and style
- Errors are typed (
rango::Error), never strings, and they carry no SQL text. Enforced by: the type signatures (anyhowonly in the CLI binary). - A wrong column or a wrong value type is a compile error with typed columns. Enforced by:
compile_faildoctests. - Formatting and lints:
cargo fmt --check,cargo clippy -D warnings;cargo docwith warnings as errors. - Minimal dependencies: every dependency must earn its place. Enforced by: review,
cargo deny.
What CI runs on every push
| Job | What it checks |
|---|---|
| Check & Lint | cargo fmt --check, cargo clippy -D warnings |
| Package, docs & MSRV | cargo doc with warnings as errors, cargo publish --dry-run, cargo check on MSRV Rust 1.94 |
| Security audit | cargo audit, cargo-deny for licenses, sources, and advisories |
| Unit tests | unit tests and the SQLite integration suite (in memory) |
| Integration tests (PostgreSQL 14, 18) | integration and CLI migration tests on PostgreSQL 14 and 18 |
| Integration tests (MySQL / MariaDB) | integration and CLI migration tests on MySQL 8.0, 8.4 and MariaDB 10.6, 11.4 |
| Coverage | cargo llvm-cov over the workspace, uploaded to Codecov (badge in the README); informative, no threshold |
A change is merged only when every job passes.
Before each release
- Run the full integration suite on PostgreSQL, SQLite, MySQL, and MariaDB.
- Run A/B benchmarks against the previous release; the method is in
benchmarks/README.md. - Run a blind usability test: an agent reads only the user docs, builds a real app, and logs every friction; the protocol and reports are in the
audit/directory of the repository. - Run a security review against the checklist in Security requirements.
- Fix findings or record a decision.
What this does not prove
- Rango is pre-1.0 and young.
- No external security audit has been done yet.
- No large production deployments have been reported yet.
- The blind usability tests are run by AI agents playing users, not by real users.
- The benchmarks are run on one machine.
Re-run it yourself
just check # fmt + clippy
just test # unit
just test-sqlite # SQLite tests
just test-postgres # PostgreSQL tests
just test-mysql # MySQL 8.4 + MariaDB 11.4 in Docker
just test-all # all test suites
just scenarios # end-to-end blog/CMS app on SQLite
just bench-sqlite # SQLite benchmark
just bench-postgres # PostgreSQL benchmark
just bench-compare # Rango vs sqlx raw, SeaORM, Diesel
just audit # dependency audit
just deny # dependency policy check
Security requirements
Every security claim Rango makes, and the test or check that proves it. Rango is written with AI assistance (see How Rango is built); the trust comes from checks you can re-run, not from this page. To report a vulnerability, see SECURITY.md.
Threat model
Trusted (the developer writes it):
- models,
#[field(default = "...")],CheckConstraintSQL and partial-unique conditions - SQL passed to
raw* - migration files
Not trusted:
- end-user input reaching filter values and string column names
- dump files given to
restore/import, and names read from the database catalog - the network between the application and the database
Not protected:
- anything built into trusted SQL from user input (
raw*, constraint SQL, field defaults) rango_orm_core::Filter::raw_where: a hidden, low-level API that takes SQL; never feed it user input- a compromised developer machine, database server, or a dependency flaw not yet in the advisory database
- denial of service through expensive but valid queries (the default 1000-row cap and
statement_timeout_mslimit the damage; they are not a defence) - TLS to a local host: localhost is not forced onto TLS, and an explicit
sslmode/ssl-modealways wins
Requirements
| Requirement | Why (CVE class) | Proved by | Docs |
|---|---|---|---|
| Values are bound parameters, never interpolated (SQLite) | Classic SQL injection | rango-tests/tests/integration_sqlite.rs test_sqlite_hostile_values_are_bound | SQL injection |
Values are bound parameters (PostgreSQL), including JSON keys and values, FTS config and query, array values in PgQueryExt | Django CVE-2024-42005, Sequelize CVE-2026-30951 | rango-tests/tests/integration.rs test_pg_hostile_values_are_boundrango-tests/tests/integration.rs test_pg_fts_lang_binds_language | SQL injection |
| Values are bound parameters (MySQL / MariaDB) | Classic SQL injection | rango-tests/tests/integration_mysql.rs test_mysql_hostile_values_are_bound | SQL injection |
| SQLite extension arguments (JSON path and value, FTS5 query) are bound | Django CVE-2024-42005 | rango-tests/tests/integration_sqlite.rs test_sqlite_hostile_values_are_bound | Extensions |
Identifiers go through one function, quote_ident, which doubles the quote character | Django CVE-2021-35042, CVE-2026-1312, CVE-2026-1287; Drizzle CVE-2026-39356 | rango-orm-core/src/query.rs test_quote_ident_escapes_double_quotesrango-orm-core/src/query.rs test_quote_ident_mysql_escapes_backticksrango-orm-core/src/model.rs quote_ident_is_one_token_roundtrip | SQL injection |
Identifiers in the query builder (order_by, filters) are quoted | same | rango-orm-core/src/filter.rs order_by_quotes_identifierrango-orm-postgres/src/query_builder/mod.rs order_by_quotes_identifier | SQL injection |
| Identifiers in the CRUD ops (insert, update, delete, filter on hostile table and column names) are quoted, run against a real database | same | rango-tests/tests/integration_sqlite.rs test_sqlite_hostile_identifiers_are_quotedrango-tests/tests/integration.rs test_pg_hostile_identifiers_are_quotedrango-tests/tests/integration_mysql.rs test_mysql_hostile_identifiers_are_quoted | SQL injection |
| Identifiers in the CLI's generated DDL are quoted (all three backends) | same | rango-orm-cli/src/migration/sql/tests.rs hostile_names_are_always_quoted_in_ddlrango-orm-cli/tests/dump_restore_sqlite.rs hostile_table_and_column_names_stay_identifiers | SQL injection |
import treats column names in the file as untrusted (PostgreSQL, MySQL) | PostgreSQL CVE-2025-8714, CVE-2025-8715 | rango-orm-cli/tests/migrations_e2e.rs import_does_not_inject_column_namesrango-orm-cli/tests/migrations_e2e_mysql.rs import_does_not_inject_column_names | Import |
restore treats table and column names, and schema SQL fragments, in a dump as untrusted | PostgreSQL CVE-2025-8714, CVE-2025-8715 | rango-orm-cli/tests/dump_restore_sqlite.rs malicious_names_never_run_injected_sqlrango-orm-cli/src/migration/snapshot.rs to_schemas_round_trips_and_rejects_injected_sql | Dump and restore |
dump / export read table and column names from the database catalog and quote them | same | rango-orm-cli/tests/dump_restore_sqlite.rs hostile_table_and_column_names_stay_identifiers | Dump and restore |
restore is all or nothing (one transaction) | Partial restore of a bad file | rango-orm-cli/tests/dump_restore_sqlite.rs restore_is_all_or_nothing | Dump and restore |
restore refuses files that are not Rango dumps, and unknown versions | Restoring arbitrary JSON | rango-orm-cli/tests/dump_restore_sqlite.rs restore_rejects_foreign_json_and_unknown_version | Dump and restore |
dump and backup files are written with mode 0600 | Secrets readable by other local users | rango-orm-cli/tests/dump_restore_sqlite.rs dump_then_restore_round_trips_rowsrango-orm-cli/tests/dump_restore_sqlite.rs backup_is_gzip_private_and_restorable | Dump and restore |
A string column name (eq, order_by, subqueries, values, aggregates, extensions) must exist in the model schema; otherwise Error::Invalid before any SQL runs | Identifier injection through column names | rango-orm-core/src/model.rs unknown_column_only_accepts_exact_namesrango-tests/tests/integration_sqlite.rs test_unknown_column_is_rejectedrango-tests/tests/integration.rs test_unknown_column_is_rejectedrango-tests/tests/integration_mysql.rs test_unknown_column_is_rejected | SQL injection |
Operators and connectors are Rust enums (Op, Connector), never strings from input | Django CVE-2025-64459, CVE-2025-59681; TypeORM CVE-2025-60542 | Not covered by a test: it holds by construction. Op and Connector are private enums in rango-orm-core/src/filter.rs, and no public method takes an operator or connector as a string. Checked by review. | SQL injection |
FieldPassword stores an Argon2id hash and verify checks a candidate against it | Plaintext passwords at rest | rango-orm-core/src/fields.rs field_password_hash_is_argon2idrango-orm-core/src/fields.rs field_password_verify_accepts_correct_and_rejects_wrongrango-orm-core/tests/field_password_unicode.rs field_password_verify_non_ascii_accents | Field types |
FieldPassword prints <redacted> in Debug | Secrets in logs | rango-orm-core/src/fields.rs field_password_debug_does_not_leak_plaintext | Field types |
FieldPassword has no Serialize or Deserialize impl | Hash or secret leaking through an API response | rango-orm-core/src/fields.rs two compile_fail,E0277 doctests on FieldPassword | Field types |
DatabaseConfig Debug redacts the password in the URL | Credentials in logs | rango-orm-core/src/config.rs database_config_debug_redacts_urlrango-orm-core/src/config.rs redact_url_replaces_passwordrango-orm-core/src/config.rs redact_url_handles_special_chars_and_query | Credentials |
Remote PostgreSQL hosts default to sslmode=require; localhost and an explicit sslmode / PGSSLMODE win | TLS downgrade (prefer) | rango-orm-postgres/src/lib.rs tls_tests::remote_hosts_require_tls_unless_the_url_says_otherwiserango-orm-core/src/config.rs local_hosts_are_not_forced_to_tls | Credentials |
Remote MySQL / MariaDB hosts default to ssl-mode=REQUIRED; localhost and an explicit mode win | TLS downgrade (PREFERRED) | rango-orm-mysql/src/lib.rs tls_tests::remote_hosts_require_tls_unless_the_url_says_otherwise | Credentials |
| Errors carry no SQL text and no bound values | Data in error messages and logs | rango-tests/tests/integration_sqlite.rs test_sqlite_bound_values_never_logged_or_in_errors (SQLite; the other backends share the same db_err shape, see the note below) | Error handling |
| Bound values are never logged | Data in logs | Partly covered. The same test checks Rango's own db.query spans. sqlx's own statement log is not asserted: its QueryLogger struct holds only the SQL text and counters (no arguments), see sqlx-core/src/logger.rs in sqlx 0.9. Re-check on every sqlx bump. | Observability |
#![forbid(unsafe_code)] in every crate root | Memory-safety bugs in Rango's own code | rango-tests/tests/forbid_unsafe.rs every_crate_root_forbids_unsafe_code | How Rango is built |
cargo audit and cargo deny run on every push | Known-vulnerable or unlicensed dependencies (RUSTSEC-2024-0363, RUSTSEC-2024-0365 need sqlx 0.8.1 or later) | Not a test: CI job audit in .github/workflows/ci.yml, policy in deny.toml | How Rango is built |
rango migrate reads MIGRATE_DATABASE_URL before DATABASE_URL, so the app can run with a DML-only role | Over-privileged application role | rango-orm-cli/tests/sqlite_rebuild.rs migrate_prefers_migrate_database_url | Credentials |
Notes
- Only SQLite asserts the error and span claims directly. PostgreSQL and MySQL map errors through the same
db_errshape and open the samedb.queryspan; there is no separate test per backend. - Database tests under
rango-testsare#[ignore]d unless the matchingintegration*feature is on (just test-sqlite,just test-postgres,just test-mysql). - Claims marked "Not covered by a test" hold by construction or by CI policy; they are listed so nobody mistakes them for tested.
rango-benchis an unpublished benchmark harness with no crate root, so it carries noforbid(unsafe_code).
Blind usability tests
Why
A unit test checks that code does what its author meant. A blind test checks that a newcomer can use Rango from the docs alone: it finds wrong docs, missing docs, confusing errors and API gaps.
Rango is written with AI assistance, and so are its blind tests: the testers are AI agents started with a fresh context and only the user docs. That is a real limit, stated below. The method and every report are public, so anyone can judge them or run the same campaigns.
Rules for the tester
Allowed inputs:
- The mdBook user guide
- The README
cargo docoutput for therango-ormcrate- Compiler and runtime output
Not allowed:
- Rango source code
- Rango tests
AGENTS.md- Git history
- Issues
Repo paths printed inside compiler errors are not followed. If the tester is stuck and only the source would help, it logs a BLOCKER: needed source entry and stops that step; each one is a doc bug.
Allowed deviations, not counted as friction:
- A path dependency on the repo
- The CLI built from the repo
Real databases only:
- A SQLite file
- A local PostgreSQL server
- MySQL in Docker
- MariaDB in Docker
No mocks. The tester logs friction as it happens, not from memory at the end.
Campaigns
Three campaigns, each a different persona building a different real app. Together they touch every page of the user guide.
| Campaign | Persona | App | Backends |
|---|---|---|---|
| A | Django developer, little SQL | Library: books, authors, members, loans; one active loan per book | SQLite, then ported to PostgreSQL |
| B | Backend engineer, knows SQL well | Multi-tenant SaaS billing API: tenants, customers, invoices, invoice lines, money as decimals | PostgreSQL |
| C | Indie developer who ships on MySQL | Event booking with seat capacity and waiting list | MySQL 8 and MariaDB, SQLite for its tests |
Each campaign follows the same steps:
- Install, run
rango init, and configure the connection. - Write the models: primary keys, foreign keys, many-to-many relations, optional fields, and constraints.
- Run
makemigrationsandmigrate. - Evolve the schema: add a field, rename a field, add a constraint, then migrate again.
- Seed data with bulk operations, at least 1,000 rows.
- Write the app's real queries: filters, relations, aggregates, and pagination.
- Run one transaction scenario where atomicity matters, for example borrow a book, book the last seat, or issue an invoice.
- Run a concurrency or retry case where the backend supports it.
- Check observability: count the SQL queries of each step from the tracing output.
- Make deliberate mistakes and judge each error message: forgot
migrate, value of the wrong type, unique violation, foreign key to a missing row, unknown column name passed as a string. - Dump, restore into an empty database, and back up.
Feature coverage
| Feature (docs page) | A | B | C |
|---|---|---|---|
| Installation, quick start, configuration | x | x | x |
| Defining models, field types | x | x | x |
Implicit primary key (#[rango::model], bigint) | x | ||
| Relations: ForeignKey, ManyToMany | x | x | x |
| Model hooks | x | ||
| Constraints, ordering, partial unique index | x | x | x |
| makemigrations (add, rename, constraint change, SQLite rebuild) | x | x | x |
migrate, least-privilege migration role (MIGRATE_DATABASE_URL) | x | x | x |
Filters, or / not, subqueries (where_exists, in_subquery) | x | x | x |
| Aggregations, decimals | x | x | x |
| select_related, prefetch_related | x | x | |
| values() | x | ||
Partial selects (select_as) | x | ||
| Keyset pagination | x | x | |
Raw SQL, stored procedure via raw | x | ||
| CRUD, get_or_create, update_or_create | x | x | x |
| Bulk create / update / upsert | x | x | x |
| Transactions, atomic_retry | x | x | x |
| Errors | x | x | x |
Tracing, slow query log, .explain() / .explain_analyze() | x | x | x |
| Backend extensions (JSON, full-text search) | x | ||
| TLS and credentials | x | x | |
| export / import, dump / restore / backup, restore across backends | x | x | x |
When a docs page is added, it gets a row and at least one campaign.
Report format
Each report is a Markdown file in audit/, named YYYY-MM-DD-<campaign>.md. The exact brief given to the tester is committed next to it, as YYYY-MM-DD-<campaign>.brief.md. The report starts with a header:
- Date
- Rango commit
- Database versions
- Persona
- The tester's model
Then it has these parts:
- (a) Friction log
- (b) Per step: lines of Rust written and number of SQL queries.
- (c) What felt simple
- (d) Doc pages wrong, missing, or misleading
- (e) Verdict: score out of 10, and whether the tester would use Rango for this app.
- (f) Decisions, written by the maintainer, not the tester
The friction log is a table:
| # | Step | Severity | What happened | Expected | Exact error | Suggested fix |
|---|
Severity is one of:
BLOCKER: could not continuePAINFUL: worked around with effortDOC-WRONG: the docs say something falseMINOR
The decisions table is:
| # | Decision | Commit or reason |
|---|
Decision is one of:
fixeddocumentedwon't fixopen
Every finding gets a line, and won't fix always gives the reason. audit/README.md lists the reports with their date, commit and score.
When
- Before each minor release (
0.x.0): all three campaigns. - Patch release: no campaign, unless it changes the public API or the docs; then only the steps it touches are rerun.
- A release ships with no open
BLOCKERorDOC-WRONGfinding.
Limits
- The testers are AI agents playing users, not real users. They read docs faster and more literally than people, and they share the blind spots of their model family.
- A blind test measures usability, not performance or security. Those have their own checks: How Rango is built and verified and Security requirements.
- Reports are kept as written, including findings that were later judged wrong.