The Django ORM, reborn in Rust.

You don't need to be a SQL expert to build an app with a database. Describe your data as Rust structs: Rango writes the tables, the migrations and the queries for you. Async and typed, on PostgreSQL, SQLite and MySQL / MariaDB.

$ cargo add rango-orm
$ cargo install rango-orm-cli

Pre-1.0: a 0.x minor release may still break the API. The package is rango-orm; you import rango.

How it works

1Write structs

A plain Rust struct is all Rango needs. No schema file, no SQL types to learn, no attributes on every field.

#[derive(Model)]
struct User {
    id: FieldUuid,
    #[field(unique)]
    email: FieldEmail,
    name: FieldVarchar<1, 100>,
    age: FieldInt,
    active: FieldBool,
    created_at: FieldDateTime,
}

#[derive(Model)]
struct Post {
    id: FieldUuid,
    author_id: ForeignKey<User>,
    title: FieldVarchar<1, 255>,
    published: FieldBool,
}

2Generate and apply

Two commands. Rango works out what changed and writes the migration for you.

$ rango init --backend postgres
$ rango makemigrations
$ rango migrate

3Read the SQL

You never have to write it, but it is plain SQL you can read. Here is what Rango generated for Post.

CREATE TABLE IF NOT EXISTS "post" (
    "id" UUID PRIMARY KEY,
    "author_id" UUID NOT NULL
      CONSTRAINT "post_author_id_fkey"
      REFERENCES "user" ("id")
      ON DELETE CASCADE,
    "title" VARCHAR(255) NOT NULL,
    "published" BOOLEAN NOT NULL
);

CREATE INDEX IF NOT EXISTS
  "post_author_id_idx"
  ON "post" ("author_id");

Simple things stay simple

Typed query builder

Query in Rust, not in SQL strings. A typo in a column name or a wrong value type is caught by the compiler, before anything reaches the database. Need OR, NOT or a subquery? or(), not() and where_exists read like plain English.

Filter & lookup

let adults = User::filter(&pool)
    .gte(User::AGE, 18)
    .eq(User::ACTIVE, true)
    .order_by(User::CREATED_AT.desc())
    .limit(20)
    .all().await?;

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

// Does not compile: &str vs a FieldInt column
// User::filter(&pool).eq(User::AGE, "eighteen");

Relations without the headache

Write ForeignKey<User> and you are done: Rango creates the real constraint, the index and the ON DELETE CASCADE. Load related rows with select_related or prefetch_related and Rango writes the join for you. ManyToMany<T> even creates the pivot table.

Relations · select_related & prefetch_related

let posts = Post::filter(&pool)
    .select_related(Post::AUTHOR_ID)
    .all().await?;

for post in &posts {
    let author = post.related::<User>(); // no SQL
    println!("{} by {}", post.title, author.name);
}

// Many-to-many: one query through the pivot table
let articles = Article::filter(&pool)
    .prefetch_related(Article::TAGS)
    .all().await?;

Migrations you never write

Add a field, rename one, change a type: run rango makemigrations and that is it. Rango detects renames, produces the same result every time, and handles the awkward cases for you, like rebuilding a SQLite table safely in one transaction.

makemigrations · migrate

$ rango makemigrations --dry-run   # preview the SQL
$ rango makemigrations --check     # exit 1 if pending (CI)
$ rango migrate

-- SQLite: type / NULL / FK change becomes a rebuild
CREATE TABLE "_rango_new_user" (...);
INSERT INTO "_rango_new_user" (...) SELECT ... FROM "user";
DROP TABLE "user";
-- ... then rename, all in one transaction

Everything else, built in

Transactions

Wrap your code in rango::atomic: everything commits together or nothing does. atomic_retry retries deadlocks for you (Postgres).

Bulk operations

Insert or update thousands of rows with one call: bulk_create, bulk_update, bulk_upsert. Rango picks the fast path.

Raw SQL, safely

When you do want SQL, raw is there, and your parameters are always bound safely.

Dump, restore, backup

One command, same on every database. No more digging through pg_dump or mysqldump flags.

Passwords

Use FieldPassword and it is hashed with Argon2id automatically. It never shows up in your logs.

Typed errors

Match on NotFound or UniqueViolation instead of parsing database error codes.

Observability

Every query is traced, and slow ones (over 500 ms by default) are logged without any setup.

TLS by default

Connections to remote Postgres and MySQL hosts are encrypted by default. Nothing to configure.

Why not sqlx, SeaORM or Diesel?

When I started Rust, my first project needed a database, so I looked for an ORM. I quickly found that usage was rather raw, a bit like SQLAlchemy in Python. Coming from Django, I believed developers shouldn't have to be SQL experts to build an application. Django does that abstraction wonderfully.

I tried several ORMs: plenty of usage simplifications, but joins, table definitions and worst of all migrations were full of commands to write. So I thought: let's take Django's mindset, bring it to Rust, and get back that ease and simplicity of using an ORM.

That's how Rango was born: Rust + Django = Rango.

Those are good tools, but they all ask you to know, or write, a fair amount of SQL. Here is what each one asks of you:

Rango compared with other Rust options
ToolWhat you writeNotes
RangoStructs. That is it.Migrations, joins and constraints are generated. Async, PostgreSQL / SQLite / MySQL & MariaDB. Young project.
DieselA table! schema, SQL migrationsStrong typing, large and mature. Sync-first core (async via diesel-async).
sqlxAll the SQL, migrations includedNot an ORM, and great at what it does. Rango is built on top of it.
SeaORMEntity + ActiveModel, migrations in RustAsync and active. Entity-first schema sync exists but is newer.
ButaneStructsSame starting idea: migrations generated from models. Describes itself as experimental; PostgreSQL and SQLite; async since 0.8; queries through a query! macro.

Rango is not the only one to generate migrations. Its goal is to be the simplest: the whole Django experience, not just the migrations. Relations, subqueries, transactions, bulk operations, hooks, constraints and backups all work without writing SQL, async from the ground up, and the same code runs on three databases.

And where Django fell short:

  • ON DELETE CASCADE is done by the database, not by deleting row by row in application code.
  • Option<T> is the one concept for nullable, instead of null=True and blank=True.
  • Queries are typed, not strings.

Simple does not mean slow

On PostgreSQL, Rango is on par with SeaORM and Diesel and close to hand-written sqlx. Mean per operation, lower is better.

Postgres 16 in Docker on the same host; single-row differences under ~5% are noise.
Benchmarksqlx (raw)RangoSeaORMDiesel-async
select_pk819 µs827 µs836 µs833 µs
select_all_10001.43 ms1.49 ms1.49 ms1.41 ms
insert_one2.01 ms1.94 ms1.92 ms2.53 ms
bulk_insert/10004.80 ms5.47 ms5.93 ms10.31 ms
update_one1.91 ms1.95 ms2.00 ms2.87 ms
transaction_10_inserts6.15 ms6.52 ms6.27 ms12.58 ms

Methodology and full results

Safe without thinking about it

You don't need to know what SQL injection is to be protected from it.

  • Values are always bound parameters, never interpolated into SQL.
  • Identifiers always go through quote_ident; string column names are checked against the model schema.
  • #![forbid(unsafe_code)] in every crate.
  • cargo audit and cargo deny run in CI.
  • A known-CVE checklist (Django, Drizzle, TypeORM, PostgreSQL and others) keeps new APIs out of those bug classes.

SQL injection · Credentials

From zero to a migrated database

$ cargo add rango-orm
$ cargo install rango-orm-cli
$ rango init --backend postgres
$ rango makemigrations
$ DATABASE_URL=postgres://user:pass@localhost/mydb rango migrate

Read the docs