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.

RangoDieselSeaORMSQLxButane
KindDjango-style ORMQuery builder + ORMAsync ORMRaw 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)] onlytable! macro + structEntity + ActiveModel, more boilerplateNone#[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_relatedManual joins✅❌✅
Real ON DELETE CASCADE✅✅✅n/a✅
Raw SQL escape hatch✅ raw*✅✅✅ it's the point✅
BackendsPG, MySQL / MariaDB, SQLitePG, MySQL, SQLitePG, MySQL, SQLitePG, MySQL, SQLitePG, SQLite
Maturity / ecosystemEarly, API not stableLarge, production at crates.ioMedium, activeLargeSmall

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

DatabaseSupportedTested in CIWhy this minimum
PostgreSQL14+14, 18gen_random_uuid() is built in from 13; 13 is end-of-life
MySQL8.0+8.0, 8.4oldest version tested
MariaDB10.6+10.6, 11.4oldest version tested
SQLite3.35+bundled 3.46RETURNING (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):

  1. --database-url CLI argument
  2. DATABASE_URL environment variable
  3. url field in rango.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

backendURL formatNotes
sqlitesqlite://path/to/db.sqlite3In-memory: sqlite::memory:
postgrespostgres://user:pass@host:5432/dbAlso accepts postgresql://
mysqlmysql://user:pass@host:3306/dbMySQL 8.0+
mariadbmariadb://user:pass@host:3306/dbMariaDB 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?;
}
FieldDefaultNotes
max_connections / min_connections20 / 2
connect_timeout10 sPool acquire timeout → Error::Timeout
idle_timeout600 s
max_lifetime1800 sRecycles old connections
test_before_acquirefalseSaves a round-trip per acquire; enable behind proxies that drop idle connections
statement_timeout_ms / lock_timeout_msserver defaultSent at connection startup (PgBouncer: add them to ignore_startup_parameters)
slow_query_threshold_ms500

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 typeRango type
StringFieldText or FieldVarchar<MIN, MAX>
Vec<u8>FieldBytes
boolFieldBool
i16 / i32 / i64FieldSmallInt / FieldInt / FieldBigInt
f32 / f64FieldFloat / FieldDouble
UuidFieldUuid (ForeignKey<M> for a reference)
DateTime<Utc> / NaiveDate / NaiveTimeFieldDateTime / FieldDate / FieldTime
serde_json::ValueFieldJson

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: FieldUuid as the first field.
  • #[rango::model(pk = "bigint")] injects id: FieldAutoBigInt, assigned by the database on insert.
  • #[rango::model(pk = "int")] injects id: FieldAutoInt.
  • "uuid" is the default.
  • If a primary key already exists (id field 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 typeSQL (Postgres)SQL (SQLite)Notes
FieldBoolBOOLEANINTEGER
FieldSmallIntSMALLINTINTEGER
FieldIntINTEGERINTEGER
FieldBigIntBIGINTINTEGER
FieldAutoIntINTEGER GENERATED BY DEFAULT AS IDENTITYINTEGER (rowid)Primary key only, None until inserted
FieldAutoBigIntBIGINT GENERATED BY DEFAULT AS IDENTITYINTEGER (rowid)Primary key only, None until inserted
FieldFloatREALREAL
FieldDoubleDOUBLE PRECISIONREAL

Text types

Rust typeSQL (Postgres)SQL (SQLite)Notes
FieldTextTEXTTEXTUnlimited length
FieldVarchar<MIN, MAX>VARCHAR(MAX)TEXTCompile-time length bounds
FieldEmailVARCHAR(254)TEXT
FieldUrlVARCHAR(2048)TEXT
FieldPassword<MIN, MAX>TEXTTEXTMIN / 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 typeSQL (Postgres)SQL (SQLite)SQL (MySQL / MariaDB)Notes
FieldDecimal<P, S>NUMERICNUMERICDECIMAL(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>BIGINTINTEGERBIGINTChecked 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:

Backendmin::<Decimal> / max::<Decimal>Exact sum
PostgreSQL, MySQL / MariaDBCompile and return an exact DecimalRead the rows and add the Decimals in Rust
SQLiteDo not compile: Decimal cannot be decoded from SQLiteSame

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 typeSQL (Postgres)SQL (SQLite)
FieldDateTimeTIMESTAMPTZTEXT (ISO 8601)
FieldDateDATETEXT
FieldTimeTIMETEXT

Other types

Rust typeSQL (Postgres)SQL (SQLite)
FieldUuidUUIDTEXT
FieldJsonJSONBTEXT
FieldBytesBYTEABLOB

Relation types

Rust typeDescription
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 = "...")]:

ValueSQLDeleting the parent…
"cascade" (default)ON DELETE CASCADEdeletes the children
"set_null"ON DELETE SET NULLsets the children's FK to NULL; the field must be Option<ForeignKey<M>> (compile error otherwise)
"set_default"ON DELETE SET DEFAULTsets the children's FK to the column default
"restrict"ON DELETE RESTRICTfails with Error::ForeignKeyViolation while children exist
"no_action"ON DELETE NO ACTIONlike 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.

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?;
}

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.

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:

  1. SELECT * FROM posts
  2. SELECT * 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

HookTriggered
before_saveBefore INSERT or UPDATE
after_saveAfter INSERT or UPDATE
before_createBefore INSERT only
after_createAfter INSERT only
before_updateBefore UPDATE only
after_updateAfter UPDATE only
before_deleteBefore DELETE
after_deleteAfter DELETE

Which operations fire hooks

OperationOrder
insertbefore_save, before_create, SQL, after_create, after_save
updatebefore_save, before_update, SQL, after_update, after_save
deletebefore_delete, SQL, after_delete
get_or_createcreate hooks on defaults: before_* always run before the statement, after_* only if a row was inserted
update_or_createcreate hooks or update hooks, depending on whether the row existed

Bulk operations skip hooks. bulk_create, bulk_update and bulk_upsert never call any hook (see Bulk operations). If a hook computes a field, such as the slug above, 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 async and return Result<(), Box<dyn Error + Send + Sync>>.
  • A before_* hook returning Err cancels the operation: no SQL runs. The caller gets Error::Hook.
  • An after_* hook returning Err is returned to the caller as Error::Hook too, but the statement has already run. Inside atomic() (or any transaction) the error rolls the write back. With a plain pool the write is already committed: use atomic() if an after_* 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.

  • --yes answers y to 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 NULL without a default, the command stops with an error that mentions the possible rename and --yes. Otherwise a drop that needs confirmation aborts with Aborted: destructive changes need confirmation (interactive, or --yes).
  • With --check, renames are never confirmed: the run only reports whether migrations are pending.

How it works

  1. Rango scans your src/ directory for Rust files containing #[derive(Model)]
  2. Extracts the schema (table name, columns, types, constraints) using syn
  3. Loads the previous schema snapshot from migrations/snapshot.json
  4. Computes the diff
  5. Generates a numbered .sql file 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

ChangeGenerated SQL
New modelCREATE TABLE IF NOT EXISTS ...
Removed modelDROP TABLE IF EXISTS ...
New fieldALTER TABLE ... ADD COLUMN ...
Removed fieldALTER TABLE ... DROP COLUMN ...
Field type changedALTER TABLE ... ALTER COLUMN ... TYPE ...
Nullable changedALTER 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 removedALTER 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 changedDROP CONSTRAINT + ADD CONSTRAINT ... REFERENCES ... (SQLite: table rebuild)
CheckConstraint / UniqueConstraint added, removed, or its SQL / columns changed under the same nameALTER TABLE ... ADD/DROP CONSTRAINT ... (SQLite: table rebuild)
Partial unique constraint (.condition(..)) added, removed or its condition changedCREATE 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:

  1. --database-url CLI flag
  2. MIGRATE_DATABASE_URL environment variable
  3. DATABASE_URL environment 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

MethodSQLExample
.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…UseWhy it is the cheap shape
Keep/drop rows based on whether a related row existswhere_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 queryin_subquery (IN (SELECT …))One subquery, values stay bound; avoids loading them into Rust
Select columns from a related table alongside the rowselect_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 themprefetch_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)]).

MethodReturns
.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:

BackendStatementExecutes the query
PostgresEXPLAIN ANALYZEyes
MySQL 8.0.18+EXPLAIN ANALYZEyes
MariaDBANALYZE FORMAT=JSONyes
SQLiteEXPLAIN 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.

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.

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 TEXT fields) when you only need a few
  • Build lightweight projections for APIs
  • Aggregate data without full model deserialization

SqlValue variants

Postgres typeSqlValue
BOOLEANSqlValue::Bool(bool)
INT2/INT4/INT8SqlValue::BigInt(i64)
FLOAT4/FLOAT8SqlValue::Double(f64)
UUIDSqlValue::Uuid(Uuid)
TEXT/VARCHAR/...SqlValue::Text(String)
NUMERIC / DECIMALSqlValue::Decimal(Decimal), at the column scale (15.00, not 15); SqlValue::NullDecimal for NULL
NULLSqlValue::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/offset as .all().
  • Relations (select_related, prefetch_related) are not supported with select_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 elsewhere insert, bulk_create, ...) as plain functions. With use rango::prelude::*; they are in scope on any backend. At the crate root (rango::raw_execute, rango::bulk_create) they exist only with the postgres feature. With only sqlite or mysql, write rango::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

BackendPlaceholder
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()),
}
}
VariantWhen
NotFoundA query expected a row and found none
TableMissing { table }The table does not exist: run rango makemigrations and rango migrate
UniqueViolation / ForeignKeyViolation / NotNullViolation / CheckViolationConstraint 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)
ConflictSerialization failure or deadlock (40001 / 40P01); is_retryable() is true
TimeoutPool acquire, statement_timeout, lock_timeout, or SQLite busy timeout
ConnectionIO, TLS, or closed pool
DecodeA row could not be decoded into the model
InvalidInvalid 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)
AbortedYour 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

FieldValue
db.systempostgresql, sqlite, or mysql
db.operationSQL operation: SELECT, INSERT, UPDATE, DELETE, ...
db.sql.tableModel table (absent on raw SQL)
db.rowsRows 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 when summary is truncated; an empty string for short statements (their SQL is in summary)
  • rows_affected
  • rows_returned
  • elapsed

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 (default 500) logs statements slower than the threshold at WARN.
  • SQLite and MySQL: fixed 500 ms.

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 typeSQLite column
FieldUuidTEXT (UUID string)
FieldBoolINTEGER (0/1)
FieldDateTimeTEXT (ISO 8601)
FieldJsonTEXT (serialized JSON)
FieldVarcharTEXT

Limitations vs Postgres

FeaturePostgresSQLite
Native UUID✅❌ (stored as TEXT)
JSONB operators✅❌ (use json_extract)
ALTER COLUMN TYPE✅❌
SET/DROP NOT NULL✅❌
Full-text searchtsvectorFTS5 (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:

  1. Keep your main rango.toml (backend = "postgres") and migrations/.
  2. Add a folder, for example tests-sqlite/, with its own rango.toml that says backend = "sqlite".
  3. 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.
  4. Load those files in the test helper above (include_str!("../tests-sqlite/migrations/0001_user.sql")).

Limits:

  • You maintain two migration sets. rango makemigrations --check in each folder tells you when one is stale.
  • SQLite is not PostgreSQL. Tests do not see native UUID, JSONB operators, DECIMAL scale, PgQueryExt methods, 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 typeMySQL / MariaDB column
FieldUuidBINARY(16)
FieldBoolBOOLEAN (TINYINT(1))
FieldInt / FieldBigInt / FieldSmallIntINT / BIGINT / SMALLINT
FieldAutoInt / FieldAutoBigIntINT / BIGINT AUTO_INCREMENT (id read back with LAST_INSERT_ID())
FieldDecimal<P, S>DECIMAL(P, S)
FieldVarchar<_, N>VARCHAR(N)
FieldTextLONGTEXT
FieldBytesLONGBLOB
FieldJsonJSON (a LONGTEXT with a json_valid check on MariaDB)
FieldDate / FieldTime / FieldDateTimeDATE / TIME(6) / DATETIME(6)

Tables are created with ENGINE=InnoDB DEFAULT CHARSET=utf8mb4.

Differences from Postgres

FeaturePostgresMySQL / 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 … WHEREskipped with a comment in the migration; enforce it in application code
get_or_createINSERT … 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)

MethodSQLDescription
.fts(col, query)to_tsvector(...) @@ plainto_tsquery(...)Full-text search
.fts_lang(col, query, lang)Same with custom languageFTS with language config
.json_contains(col, val)col @> val::jsonbJSONB containment
.json_has_key(col, key)col ? keyJSONB key existence
.json_field_eq(col, key, val)col->>'key' = valJSONB field equality
.array_contains(col, vals)col @> ARRAY[...]Array containment

SqliteQueryExt (SQLite)

MethodSQLDescription
.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 NULLJSON 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 locks
  • SqliteQueryExt: 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 init fails if rango.toml already exists
  • The url field is commented out by default — use DATABASE_URL env var for credentials
  • The backend field (sqlite, postgres, mysql or mariadb) determines the SQL dialect for makemigrations

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

OptionDefaultDescription
--tableall tablesTable to export
--formatjsonOutput format: json or csv
--outputstdoutOutput file path
--database-urlDATABASE_URLDatabase 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

OptionDefaultDescription
--tablefrom __table fieldTarget 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.
--formatauto-detectedjson or csv
--replaceskipReplace existing rows on conflict
--database-urlDATABASE_URLDatabase connection URL

Conflict behavior

ModeSQLUse case
Default (skip)INSERT OR IGNORE / ON CONFLICT DO NOTHINGLoad fixtures without overwriting
--replaceINSERT OR REPLACE / ON CONFLICT DO UPDATESync/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_version
  • backend
  • created_at
  • schema: the latest migration snapshot, or null
  • tables: [{ "name": ..., "rows": [ {column: value, ...} ] }]

A dump from one backend can be restored on another.

dump

rango dump
OptionDefaultDescription
--schema-only—Schema snapshot, no rows. Conflicts with --data-only.
--data-only—Rows, no schema.
-t, --table <name>all tablesTable to dump. Repeatable.
-o, --output <file>stdoutOutput file. Written with 0600 permissions.
-m, --migrations <dir>migrations / [migrations] dir from rango.tomlWhere the schema snapshot is read.
-d, --database-urlDATABASE_URLDatabase 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.

OptionDefaultDescription
--dir <dir>backupsBackup directory. Created if missing.
-d, --database-urlDATABASE_URLDatabase 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

PathSafe?
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.toml does not contain production credentials
  • .env files 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-core shows no sqlx).
  • SQL generation is shared, not copied per backend: one query builder in core, one Dialect per 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.
  • FieldPassword stores 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 audit and cargo deny in CI.

Performance

  • No N+1 by construction: select_related is one JOIN, prefetch_related one query per relation. Enforced by: integration tests, query counts in the blind tests.
  • RETURNING instead 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 (anyhow only in the CLI binary).
  • A wrong column or a wrong value type is a compile error with typed columns. Enforced by: compile_fail doctests.
  • Formatting and lints: cargo fmt --check, cargo clippy -D warnings; cargo doc with warnings as errors.
  • Minimal dependencies: every dependency must earn its place. Enforced by: review, cargo deny.

What CI runs on every push

JobWhat it checks
Check & Lintcargo fmt --check, cargo clippy -D warnings
Package, docs & MSRVcargo doc with warnings as errors, cargo publish --dry-run, cargo check on MSRV Rust 1.94
Security auditcargo audit, cargo-deny for licenses, sources, and advisories
Unit testsunit 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
Coveragecargo 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 = "...")], CheckConstraint SQL 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_ms limit the damage; they are not a defence)
  • TLS to a local host: localhost is not forced onto TLS, and an explicit sslmode / ssl-mode always wins

Requirements

RequirementWhy (CVE class)Proved byDocs
Values are bound parameters, never interpolated (SQLite)Classic SQL injectionrango-tests/tests/integration_sqlite.rs test_sqlite_hostile_values_are_boundSQL injection
Values are bound parameters (PostgreSQL), including JSON keys and values, FTS config and query, array values in PgQueryExtDjango CVE-2024-42005, Sequelize CVE-2026-30951rango-tests/tests/integration.rs test_pg_hostile_values_are_bound
rango-tests/tests/integration.rs test_pg_fts_lang_binds_language
SQL injection
Values are bound parameters (MySQL / MariaDB)Classic SQL injectionrango-tests/tests/integration_mysql.rs test_mysql_hostile_values_are_boundSQL injection
SQLite extension arguments (JSON path and value, FTS5 query) are boundDjango CVE-2024-42005rango-tests/tests/integration_sqlite.rs test_sqlite_hostile_values_are_boundExtensions
Identifiers go through one function, quote_ident, which doubles the quote characterDjango CVE-2021-35042, CVE-2026-1312, CVE-2026-1287; Drizzle CVE-2026-39356rango-orm-core/src/query.rs test_quote_ident_escapes_double_quotes
rango-orm-core/src/query.rs test_quote_ident_mysql_escapes_backticks
rango-orm-core/src/model.rs quote_ident_is_one_token_roundtrip
SQL injection
Identifiers in the query builder (order_by, filters) are quotedsamerango-orm-core/src/filter.rs order_by_quotes_identifier
rango-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 databasesamerango-tests/tests/integration_sqlite.rs test_sqlite_hostile_identifiers_are_quoted
rango-tests/tests/integration.rs test_pg_hostile_identifiers_are_quoted
rango-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)samerango-orm-cli/src/migration/sql/tests.rs hostile_names_are_always_quoted_in_ddl
rango-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-8715rango-orm-cli/tests/migrations_e2e.rs import_does_not_inject_column_names
rango-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 untrustedPostgreSQL CVE-2025-8714, CVE-2025-8715rango-orm-cli/tests/dump_restore_sqlite.rs malicious_names_never_run_injected_sql
rango-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 themsamerango-orm-cli/tests/dump_restore_sqlite.rs hostile_table_and_column_names_stay_identifiersDump and restore
restore is all or nothing (one transaction)Partial restore of a bad filerango-orm-cli/tests/dump_restore_sqlite.rs restore_is_all_or_nothingDump and restore
restore refuses files that are not Rango dumps, and unknown versionsRestoring arbitrary JSONrango-orm-cli/tests/dump_restore_sqlite.rs restore_rejects_foreign_json_and_unknown_versionDump and restore
dump and backup files are written with mode 0600Secrets readable by other local usersrango-orm-cli/tests/dump_restore_sqlite.rs dump_then_restore_round_trips_rows
rango-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 runsIdentifier injection through column namesrango-orm-core/src/model.rs unknown_column_only_accepts_exact_names
rango-tests/tests/integration_sqlite.rs test_unknown_column_is_rejected
rango-tests/tests/integration.rs test_unknown_column_is_rejected
rango-tests/tests/integration_mysql.rs test_unknown_column_is_rejected
SQL injection
Operators and connectors are Rust enums (Op, Connector), never strings from inputDjango CVE-2025-64459, CVE-2025-59681; TypeORM CVE-2025-60542Not 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 itPlaintext passwords at restrango-orm-core/src/fields.rs field_password_hash_is_argon2id
rango-orm-core/src/fields.rs field_password_verify_accepts_correct_and_rejects_wrong
rango-orm-core/tests/field_password_unicode.rs field_password_verify_non_ascii_accents
Field types
FieldPassword prints <redacted> in DebugSecrets in logsrango-orm-core/src/fields.rs field_password_debug_does_not_leak_plaintextField types
FieldPassword has no Serialize or Deserialize implHash or secret leaking through an API responserango-orm-core/src/fields.rs two compile_fail,E0277 doctests on FieldPasswordField types
DatabaseConfig Debug redacts the password in the URLCredentials in logsrango-orm-core/src/config.rs database_config_debug_redacts_url
rango-orm-core/src/config.rs redact_url_replaces_password
rango-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 winTLS downgrade (prefer)rango-orm-postgres/src/lib.rs tls_tests::remote_hosts_require_tls_unless_the_url_says_otherwise
rango-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 winTLS downgrade (PREFERRED)rango-orm-mysql/src/lib.rs tls_tests::remote_hosts_require_tls_unless_the_url_says_otherwiseCredentials
Errors carry no SQL text and no bound valuesData in error messages and logsrango-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 loggedData in logsPartly 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 rootMemory-safety bugs in Rango's own coderango-tests/tests/forbid_unsafe.rs every_crate_root_forbids_unsafe_codeHow Rango is built
cargo audit and cargo deny run on every pushKnown-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.tomlHow Rango is built
rango migrate reads MIGRATE_DATABASE_URL before DATABASE_URL, so the app can run with a DML-only roleOver-privileged application rolerango-orm-cli/tests/sqlite_rebuild.rs migrate_prefers_migrate_database_urlCredentials

Notes

  • Only SQLite asserts the error and span claims directly. PostgreSQL and MySQL map errors through the same db_err shape and open the same db.query span; there is no separate test per backend.
  • Database tests under rango-tests are #[ignore]d unless the matching integration* 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-bench is an unpublished benchmark harness with no crate root, so it carries no forbid(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 doc output for the rango-orm crate
  • 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.

CampaignPersonaAppBackends
ADjango developer, little SQLLibrary: books, authors, members, loans; one active loan per bookSQLite, then ported to PostgreSQL
BBackend engineer, knows SQL wellMulti-tenant SaaS billing API: tenants, customers, invoices, invoice lines, money as decimalsPostgreSQL
CIndie developer who ships on MySQLEvent booking with seat capacity and waiting listMySQL 8 and MariaDB, SQLite for its tests

Each campaign follows the same steps:

  1. Install, run rango init, and configure the connection.
  2. Write the models: primary keys, foreign keys, many-to-many relations, optional fields, and constraints.
  3. Run makemigrations and migrate.
  4. Evolve the schema: add a field, rename a field, add a constraint, then migrate again.
  5. Seed data with bulk operations, at least 1,000 rows.
  6. Write the app's real queries: filters, relations, aggregates, and pagination.
  7. Run one transaction scenario where atomicity matters, for example borrow a book, book the last seat, or issue an invoice.
  8. Run a concurrency or retry case where the backend supports it.
  9. Check observability: count the SQL queries of each step from the tracing output.
  10. 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.
  11. Dump, restore into an empty database, and back up.

Feature coverage

Feature (docs page)ABC
Installation, quick start, configurationxxx
Defining models, field typesxxx
Implicit primary key (#[rango::model], bigint)x
Relations: ForeignKey, ManyToManyxxx
Model hooksx
Constraints, ordering, partial unique indexxxx
makemigrations (add, rename, constraint change, SQLite rebuild)xxx
migrate, least-privilege migration role (MIGRATE_DATABASE_URL)xxx
Filters, or / not, subqueries (where_exists, in_subquery)xxx
Aggregations, decimalsxxx
select_related, prefetch_relatedxx
values()x
Partial selects (select_as)x
Keyset paginationxx
Raw SQL, stored procedure via rawx
CRUD, get_or_create, update_or_createxxx
Bulk create / update / upsertxxx
Transactions, atomic_retryxxx
Errorsxxx
Tracing, slow query log, .explain() / .explain_analyze()xxx
Backend extensions (JSON, full-text search)x
TLS and credentialsxx
export / import, dump / restore / backup, restore across backendsxxx

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:

#StepSeverityWhat happenedExpectedExact errorSuggested fix

Severity is one of:

  • BLOCKER: could not continue
  • PAINFUL: worked around with effort
  • DOC-WRONG: the docs say something false
  • MINOR

The decisions table is:

#DecisionCommit or reason

Decision is one of:

  • fixed
  • documented
  • won't fix
  • open

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 BLOCKER or DOC-WRONG finding.

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.