API conventions
Who this page is for. This is an advanced reference for Rust developers working with or on the framework's code — it explains the naming, return-type and module conventions behind Rustango's Rust API. It is not a guide to calling a Rustango app's REST API over HTTP. If that's what you want, start with ViewSets (build a REST API) and the glossary (plain-language terms); come back here once you're writing Rust against the framework.
This page explains the patterns Rustango's API follows, so you can predict how any method behaves before you read its docs. If you're contributing or auditing a feature, these are the rules.
Table of contents
- Naming
- Constructors
- Return types
- Async vs sync
- The pool argument
- Filtering
- Errors
- Module naming
- Builders vs config structs
- Feature flags
- Macros vs runtime
- Contributing
Naming
The name of a method tells you what it does. Once you learn these suffixes, you can guess most of the API.
Functions
-
fetch(&pool),count(&pool),first(&pool),find(pk, &pool)— the bare name takes arustango::sql::Pooland is the everyday path. It works on Postgres, MySQL and SQLite, picking the dialect internally. This is what nearly all app code wants. -
fetch_on(executor),count_on(executor)— the_onsuffix means "run this against the executor I'm handing you" — a connection or an open transaction rather than the pool. Reach for it when you need several statements inside one transaction._onmethods are Postgres-only (#[cfg(feature = "postgres")]). -
Writes invert this, and it is the one place the rule does not hold.
save(&pool),insert(&pool)anddelete(&pool)take a driver-specificsqlx::PgPool, so on a build without thepostgresfeature they do not exist at all — selectingsqlitemakes the method vanish rather than fail with anything that names the cause. The multi-backend versions carry the_poolsuffix:save_pool,insert_pool,delete_pool, each takingrustango::sql::Pool.bare name multi-backend version Reads ( QuerySet)fetch(&pool)— already multi-backendis the bare name Writes (model) save(&pool)— Postgres onlysave_pool(&pool)So the short name is the narrow one for writes and the broad one for reads. That inversion is a wart, not a design: it is tracked in #1293 and will be resolved with a deprecation cycle rather than a rename. Until then, if you are not on Postgres, write
save_pool/insert_pool/delete_pool. -
from_X(value)— converts FROM another value (e.g.from_model(post),from_base32(s)). -
with_X(value)— a builder method that sets one option and returns the object, so you can chain calls (e.g.with_default_ttl(d),with_access_ttl(secs)). -
new()— the minimal constructor. Any arguments it takes are required dependencies (e.g.RedisCache::new(url)— you can't build the cache without a URL).
Types
These follow standard Rust casing, the same as Python's PEP 8 split between classes and functions:
PascalCase— types, traits, and enum variants (like Python classes).snake_case— modules, functions, fields, and local variables.SCREAMING_SNAKE_CASE— constants, plus theModel::SCHEMAconstant that the derive macro generates for each model.Boxed*— an alias forArc<dyn Trait>, a thread-safe shared pointer to a trait object (the Rust way to hold "any implementation of this interface"). For exampleBoxedCache = Arc<dyn Cache>. This is the standard type for a pluggable backend you can swap out.
Modules
- Singular when the module holds ONE main type or concept:
cache,email,storage,signed_url,request_id. - Plural when the module holds a COLLECTION of items:
bulk_actions,api_keys,passwords,forms,signals.
Constructors
How you build an object depends on what it needs. There are a few standard shapes:
| Pattern | When | Example |
|---|---|---|
T::new() | Minimal — no required dependencies | InMemoryCache::new(), Validator::new() |
T::new(arg) | One required dependency | EnvSecrets::with_prefix(s), RedisCache::new(url) |
T::with_X(arg) | Builder-style override after new() | InMemoryCache::with_default_ttl(d), JwtLifecycle::new(s).with_access_ttl(60) |
T::from_X(arg) | Convert FROM Y | TotpSecret::from_base32(s), Locale::new(s) (sometimes from_str) |
T::for_Y(arg) | Build T scoped to a specific Y | ViewSet::for_model(schema) |
Avoid this: T::with_X_and_Y_and_Z(a, b, c) — one constructor that takes everything. Split it into new(...) plus chained .with_*() calls instead.
Return types
A method's return type tells you how it can fail. Rust has no exceptions, so failure is part of the return value. There are three shapes.
Result<T, E> — like a function that either returns a value or throws. You get either the value T or an error E with details. Use it for operations that can fail and where the why matters:
- I/O:
pool.fetch(...).await -> Result<_, sqlx::Error> - Validation:
Form::parse(data) -> Result<Self, FormErrors> - Issuance:
JwtLifecycle::issue_pair_with(uid, claims) -> Result<_, JwtIssueError>
Option<T> — either a value (Some) or nothing (None), like a nullable field. Use it when "nothing found" is a normal outcome and you don't need an error message explaining why:
- Lookups:
cache.get(k) -> Result<Option<String>, _>(theResultcovers I/O failure; theOptioncovers "key not present") - Verification:
async JwtLifecycle::verify_access(token) -> Option<Claims>("expired or invalid" is an expected outcome, soNoneis enough) - Optional config reads:
env::optional("FOO") -> Result<Option<T>, _>
bool — a plain yes/no when no further detail is needed:
cache.exists(k) -> Result<bool, _>(theResultcovers I/O; theboolis the answer)JwtLifecycle::revoke(token) -> bool(true = added to the blacklist)disconnect_pre_save(id) -> bool(true = an entry was removed)
Result<Option<T>> or Result<T> with a NotFound error? Both can express "lookup failed," so pick by how exceptional "not found" is:
- Use
Result<Option<T>>when "not found" is routine — your code almost always branches onSome/Noneanyway. - Use
Result<T>with aNotFounderror variant when "not found" is exceptional — something you'd log as a warning or turn into a 404.
Async vs sync
The rule of thumb: if a method waits on something (the database, network, or disk), it's async and you must .await it. If it just computes, it's a normal sync call. This table spells it out.
| Operation | Sync or async? |
|---|---|
| Trait method that touches I/O (DB, network, file) | async |
Trait method that's pure compute (hash, verify, encode) | sync |
Builder methods (with_X, chainable setters) | sync |
Macros (derive(Model), derive(Serializer)) | N/A (compile-time) |
Signal connect_* (registers a receiver) | sync |
Signal send_* (dispatches to async receivers) | async |
Exception: Cache::set is async even though the in-memory version (InMemoryCache::set) never actually waits. The trait is shaped for the Redis case, which does. This is intentional: a trait method should be async if any reasonable implementation needs to wait, so all backends share one signature.
The pool argument
Every ORM call takes a pool or executor (the database handle) as its last argument. You pass the connection in every time, rather than relying on a hidden global:
post.save_on(&pool).await?
Post::objects().filter(...).fetch_on(&pool).await?
send_post_save(&post, ctx).await // ⚠️ no pool — signals are pool-free
One exception: signals don't take a pool, because they never touch the database. The rule holds: anything that hits the DB takes the pool; anything that doesn't, doesn't.
Why pass it every time? Rust prefers dependencies you can see over hidden global state. Keeping the connection in thread-local storage breaks down in Rust's async world, where a task can hop between threads mid-request. The downside is more typing; the upside is that you can grep for every place that touches the database.
If you find yourself passing &pool through ten layers of function calls, accept impl Executor once at the public entry point and let the internal helpers share that single connection.
Filtering
There are three ways to filter a queryset, and they all combine in one query. Pick by where the filter comes from.
// 1. HTTP query string (set via ViewSet filter_fields, parsed at request time)
// GET /api/posts?author_id=42&status__ne=archived
// 2. String-keyed (lookup at compile of the queryset; runtime field name resolution)
Post::objects().filter("author_id", Op::Eq, SqlValue::I64(42));
// 3. Typed columns (compile-time field check)
Post::objects().where_(Post::author_id.eq(42));
| Syntax | Use when |
|---|---|
| HTTP query | Public API endpoints — the ViewSet parses these out of the query string for you |
String-keyed .filter | Generic CRUD or admin code, where field names come from config and aren't known at compile time |
Typed .where_ | Your app code — the preferred default. The compiler checks the field exists and the types match |
You can mix all three in a single queryset.
Errors
Rustango has 20+ error types — one per module — instead of a single catch-all exception class. They form a loose hierarchy, and a top-level type ties them together so you rarely deal with them individually.
| Layer | Module | Error type |
|---|---|---|
| ORM I/O | sql::* | ExecError |
| ORM SQL writer | sql::* | SqlError (variant of ExecError::Sql) |
| Migrations | migrate::* | MigrateError |
| Forms | forms::* | FormError (single) + FormErrors (multi) + ModelFormError |
| Cache | cache::* | CacheError |
email::* | MailError | |
| Storage | storage::* | StorageError |
| Auth backends | tenancy::auth_backends | AuthError |
| JWT | tenancy::jwt_lifecycle | JwtIssueError |
| API keys | api_keys::* | ApiKeyError |
| Passwords | passwords::* | PasswordError |
| Webhooks | webhook::* | (returns bool, no dedicated error) |
| Signed URLs | signed_url::* | SignedUrlError |
| Bulk actions | bulk_actions::* | BulkActionError |
| Fixtures | fixtures::* | FixtureError |
| IP filter | ip_filter::* | IpFilterError |
| i18n | i18n::* | I18nError |
| Env | env::* | EnvError |
| Secrets | secrets::* | SecretsError |
| API responses | api_errors::* | ApiError (HTTP-shaped, not internal) |
The one to use in handlers: there's a top-level RustangoError enum (exported from lib.rs, along with the alias RustangoResult<T> = Result<T, RustangoError>). It wraps every error above with From conversions, so the ? operator promotes any module error into it automatically. It also implements IntoResponse, meaning each variant maps to a sensible HTTP status when returned from a handler. The split is simple: use the specific per-module errors deep in your code, and RustangoError / RustangoResult at the handler boundary. For errors from third-party crates, RustangoError::other(msg) / RustangoError::other_from(e) wrap any std::error::Error + Send + Sync + 'static.
A handler example:
use rustango::api_errors::ApiError;
async fn handler() -> Result<Json<X>, ApiError> {
let post = Post::objects().get(&pool, 1).await
.map_err(|e| ApiError::internal(e.to_string()))?;
Ok(Json(post))
}
ApiError implements IntoResponse, so returning it produces its JSON shape automatically: {"error": <machine code>, "message": …, "status": …, "details": …}.
The framework's own JSON errors use the same shape: ViewSets, tenant and Principal rejections, media, the admin's JSON endpoints, body limits, rate limits and maintenance mode. A 5xx logs its cause and sends a generic message. See ViewSets — error response shapes.
Module naming
A module's name should let you guess the type names inside it without opening the file.
| Module | Hosts | Lookup confidence |
|---|---|---|
cache | Cache trait, *Cache impls | high |
email | Mailer trait, Email, *Mailer impls | high |
storage | Storage trait, *Storage impls | high |
signed_url | sign, verify free fns | medium |
text | slugify, html_escape, truncate free fns | medium |
bulk_actions | BulkActionRegistry, BulkAction, Bulk*Action impls | high |
api_keys | generate_key, verify_key, split_token free fns | medium |
Avoid this: a module that holds an unrelated grab-bag (utils, helpers, common). If you can't name the single concept it covers, it shouldn't be a module.
Builders vs config structs
There are two ways to hand over a configured object. Pick based on how users will set it up.
Builder: chained setters, no Default
let l = SecurityHeadersLayer::strict()
.csp(...)
.header("x-extra", "v");
Use when:
- Most users start from a preset and tweak
- Setters express intent (e.g.
.errors_only()reads better than.log_success(false)) - The struct has many optional fields (10+)
Config struct: set fields directly, fall back to Default
let l = AccessLogLayer {
log_success: false,
include_ip: true,
slow_threshold_ms: 500,
..Default::default()
};
Use when:
- Users want to be explicit about every field
- Reflection / serialization matters
- Updating in place is common (
config.field = ...)
As a rule, Rustango uses builders for HTTP middleware (security_headers, cors, rate_limit, and so on) and config structs for plain data carriers (Email, AccessLogLayer, RateLimitLayer's internal state).
Feature flags
A feature is a Cargo build flag (Cargo.toml's [features]) that switches a chunk of the crate on or off — the list of parts your build includes, resolved at compile time. Every module that pulls in an extra dependency sits behind one. The default set is "you almost certainly want these":
default = ["postgres", "batteries"]
batteries = [
"manage", "admin", "config", "forms", "serializer", "cache", "signals",
"email", "storage", "scheduler", "secrets", "totp", "webhook",
"webhook-delivery", "api_keys", "passwords", "signed_url", "notifications",
"casts", "jobs", "jobs-postgres", "auth_flows", "sse", "websocket",
"oauth2", "http-client", "compression", "openapi", "csp-nonce", "sessions",
"hmac-auth", "jwt", "uploads", "storage-s3", "media", "runserver",
"template_views",
]
The indirection is deliberate: batteries is a single name a downstream
crate can switch off — default-features = false, features = ["postgres"] —
without having to restate the list. The alternative, spelling all thirty-seven
into default, means anyone opting out has to know all thirty-seven.
Off by default: features that pull in heavy dependencies or external services:
tenancy— addsargon2,hmac,sha2,cookie,tower(most apps don't need it)cache-redis— adds therediscrate (most apps are fine with the in-memory cache)csrf— turned on automatically byadmin, but available on its own too
To trim a binary that doesn't need everything, opt out of the defaults and list only what you use:
rustango = { version = "0.60", default-features = false, features = ["postgres", "admin"] }
Macros vs runtime
A macro is code that generates code at compile time (#[derive(Model)] and friends) — roughly what a Rails generator does, except it runs every build and the compiler checks the result. The split below decides what's done by a macro versus plain runtime code.
| Concern | Macro or runtime? |
|---|---|
Schema metadata for inventory | macro (#[derive(Model)]) |
| Schema-driven query building | runtime (uses the &'static ModelSchema from the macro) |
| Form parsing | macro for the struct (#[derive(Form)]); runtime for the parsing logic |
| Serializer field selection | macro (#[derive(Serializer)]) — emits a from_model + custom Serialize |
| Migration ops | runtime (SchemaSnapshot diff) |
| Signal dispatch | runtime (TypeId-keyed registry, no per-model macro) |
| Auth backend pattern matching | runtime (#[async_trait] on AuthBackend) |
Rule: use a macro for anything the compiler can verify up front (field names must exist, types must match). Use runtime code for anything that varies per request or per deployment.
Contributing
When you add a new feature, follow these steps:
- One module per concept, in
crates/rustango/src/<name>.rsor<name>/mod.rs. - Add module-level rustdoc with a "Quick start" example in a
// ignoreblock. - Add a feature flag if you pull in a new dependency — name it after the module (
feature = "<name>"). - Re-export the module from
lib.rswith a one-line rustdoc. - Put unit tests in the same file, behind
#[cfg(test)] mod tests— no database unless you truly need one. - Put integration tests in
crates/rustango/tests/<name>.rsfor the end-to-end story. - Don't add a new error type unless the existing ones don't fit — extend an existing enum first.
- Follow the return-type guide when choosing
Result,Option, orbool. - Adding a
managesubcommand? Wire it into thematch cmddispatcher andprint_help, add a test incrates/rustango/tests/migrate_manage.rs, and document a row indocs/manage.md. - Update
CHANGELOG.mdwith anAddedentry under the next version.
When you break the API:
- Mark the old item
#[deprecated(since = "...", note = "use X instead")]and keep it for one full minor version before removing it. - Record it in
CHANGELOG.mdunderBreaking changes. - Link the migration path from the release notes.
