Rustango: Bringing Django's Batteries to Rust
A batteries-included web framework that ports Django's developer experience - the typed ORM, migrations, admin, auth, and management commands - to async Rust, without giving up compile-time guarantees.
I love Django. After years of shipping products with it, the framework's philosophy is baked into how I think: a typed model layer, migrations that write themselves, an admin you get for free, and a management CLI that ties it all together. I also love Rust - the compiler that refuses to let a whole class of bugs reach production, and the runtime that does not need a garbage collector or a babysitter.
For a long time those two loves lived in separate worlds. Rustango is my attempt to bring them together: a batteries-included web framework that ports Django's developer experience to async Rust - without giving up compile-time guarantees.
A typed ORM that reads like Django
The heart of any Django-style framework is the model layer. In Rustango you derive Model on a plain struct and annotate it with the table, primary key, and column constraints. The derive macro generates a typed column set and a chainable QuerySet - so the query builder is checked by the compiler, not discovered at runtime.
#[derive(Model, Default, Serialize, Deserialize)]
#[rustango(table = "blog_post", app = "blog")]
pub struct Post {
#[rustango(primary_key)]
pub id: Auto<i64>,
#[rustango(max_length = 200)]
pub title: String,
pub published: bool,
#[rustango(auto_now_add)]
pub created_at: Auto<DateTime<Utc>>,
}
// A chainable, type-checked QuerySet - column names are real symbols.
let recent = Post::objects()
.where_(Post::published.eq(true))
.order_by(&[("created_at", false)])
.fetch(pool)
.await?;
There is no stringly-typed query DSL to typo. Post::published is a generated symbol; misspell it and the build fails. The same column metadata powers inserts, updates, and the schema diff the migration engine consumes.
Migrations, generated and applied
Rustango models carry a SCHEMA the framework can compare against the database. The CLI generates migration files from the diff and applies them per database - and because Rustango is multi-tenant from the ground up, it knows the difference between the shared registry schema and each tenant's schema.
cargo run -- makemigrations # diff models -> migration files
cargo run -- migrate-registry # apply the shared/registry schema
cargo run -- migrate-tenants # apply every tenant database
The admin you did not have to write
Annotate a model with an admin(...) block - list columns, ordering, search - and Rustango renders a full CRUD admin for it: list views with filters and pagination, create/edit forms generated from your field widgets, and bulk actions. It is the single feature that makes Django feel like a cheat code, and it is the one I missed most in Rust.
#[rustango(
table = "blog_post", app = "blog",
admin(list_display = "title, published, created_at", ordering = "-created_at")
)]
pub struct Post { /* ... */ }
Batteries: auth, sessions, forms, CSRF, caching
A framework earns the word batteries-included by the boring things it ships so you never write them again. Rustango brings signed sessions, a password/auth-flow stack, a forms layer with CSRF protection, a cache (including full-page caching), signals, signed URLs, and a manage::Cli that wires routing, tenancy, migrations, and your own commands into one binary. Routing sits on top of Axum and Tokio, so the async story is the mature one the Rust ecosystem already trusts.
Django's promise was never the syntax - it was that the framework had already made the boring decisions for you. Rustango keeps that promise, and the compiler keeps you honest.
Was it worth it?
Porting a framework is not a weekend project, and Rust asks for more up front than Python does. But the payoff is real: the same model definition drives the database, the admin, and the API, and the borrow checker turns an entire category of production incidents into compile errors. If you have ever wished Django ran at Rust speeds with Rust guarantees, Rustango is what that wish looks like in code.