Database routing (multiple databases)
Route different models to different databases with named pools, per-model and per-plugin routing, per-database migrations.
Database routing
umbral can talk to more than one database at once. You register named connection pools ("default", "analytics", "archive", …), then route individual models - or every model a plugin owns - to a specific pool. Each terminal query resolves its pool automatically; migrations split per database, each with its own tracking table.
The routing model works at two levels: a plugin can own the database for all the models it contributes, with per-model overrides on top.
Register named pools
App::build() is synchronous, so it does not open connections for you - connect each pool first (async), then register it under an alias. A "default" pool is required.
use umbral::App; // connect (async) - umbral::db::connect picks sqlite vs postgres from the URLlet primary = umbral::db::connect("postgres://user:pw@localhost/app").await?;let analytics = umbral::db::connect("postgres://user:pw@localhost/analytics").await?; App::builder() .settings(settings) .database("default", primary) // required .database("analytics", analytics) // any number of extra aliases // .plugin(...) ... .build()?;.database(alias, pool) accepts a sqlx::SqlitePool, a sqlx::PgPool, or an already-built DbPool. The "default" pool's backend must match settings.database_url's backend, or build() fails with a clear DatabaseBackendMismatch rather than a confusing query-time error.
Connection URLs for extra databases can come from the environment: UMBRAL_DATABASES__ANALYTICS=postgres://… populates settings.databases["analytics"]. umbral does not auto-connect those - read the URL from settings.databases[alias], connect it, and register it with .database(...) yourself. (Auto-connecting the env map is a convenience that doesn't exist yet.)
Route a model
Point a single model at a pool with #[umbral(database = "<alias>")]:
#[derive(Debug, Clone, sqlx::FromRow, Model)]#[umbral(table = "page_view", database = "analytics")]pub struct PageView { pub id: i64, pub path: String, pub viewed_at: DateTime<Utc>,}Every PageView::objects()… query and PageView's migrations now run against the "analytics" pool. With no attribute, a model uses "default".
Route a whole plugin
A plugin can set the default pool for all the models it contributes by overriding Plugin::database():
impl Plugin for AnalyticsPlugin { fn database(&self) -> Option<&'static str> { Some("analytics") } // every model this plugin registers routes to "analytics" // unless a model overrides it with #[umbral(database = "...")]}A per-model #[umbral(database = "...")] supersedes the plugin default - useful when a plugin keeps most models on one database but parks one (an archive table, say) on another.
Resolution order
For a given query, the pool is chosen by this precedence (resolve_pool):
- Explicit per-query override -
.on(&pool)on the QuerySet. - Per-model alias -
#[umbral(database = "...")]/Model::DATABASE. - Per-plugin default -
Plugin::database(). "default"- everything else.
(2) and (3) are flattened into one model→alias map at build() time, with the per-model override winning, so at query time the lookup is a single O(1) step.
Per-query override
.on() forces one query onto a specific pool, ahead of all model/plugin routing:
PageView::objects().filter(page_view::PATH.eq("/")).on(&pool).fetch().await?;This is primarily a test / explicit-pool escape hatch - .on() currently takes a &sqlx::SqlitePool.
Migrations are per-database
umbral migrate reads each model's routed alias and applies that model's migrations to the right pool. Every database gets its own umbral_migrations tracking table, and a single migration run splits its operations per database by each table's alias (table_alias). So default and analytics track their applied migrations independently - you can migrate one without touching the other.
Boot-time safety
Routing typos fail at build(), not at runtime:
- A plugin or model pointing at an unregistered alias →
BuildError::PluginDatabaseAlias { plugin, alias }. - The
"default"pool's backend not matchingsettings.database_url→BuildError::DatabaseBackendMismatch.
So a misrouted model surfaces the moment the app boots, with the offending plugin + alias named.
Cross-database foreign keys
A foreign key whose target model lives on a different database can't be a real DB constraint - REFERENCES can't span pools. umbral guards this at boot: if a model on one database has a ForeignKey to a model on another, App::build() fails with BuildError::CrossDatabaseForeignKey { model, field, model_db, target_db }. You can't ship an invalid FOREIGN KEY by accident.
To make a cross-database relation legal, opt the field out of the physical constraint:
#[derive(Debug, Clone, sqlx::FromRow, Model)]#[umbral(table = "page_view", database = "analytics")]pub struct PageView { pub id: i64, #[umbral(db_constraint = false)] // `user` lives on "default" - keep this FK logical pub user: ForeignKey<User>, pub path: String,}db_constraint = false keeps the FK logical: the column and its fk_target are unchanged, so joins, select_related, and app-level existence checks all keep working - but the migration emits no physical REFERENCES. It works for same-database FKs too (e.g. to break a circular dependency). The default is true (emit the constraint).
Read/write (replica) split
The static per-model/per-plugin routing above is resolved by name at boot. To take routing decisions over yourself - send reads to a replica and writes to the primary, or route by a per-request key - install a DatabaseRouter. It's a single trait impl; the models, handlers, and the rest of the ORM are untouched.
use umbral::db::{Alias, DatabaseRouter, RouteContext};use umbral::migrate::ModelMeta; struct ReplicaRouter;impl DatabaseRouter for ReplicaRouter { fn db_for_read(&self, _m: &ModelMeta, _c: &RouteContext) -> Alias { Alias::new("replica") } fn db_for_write(&self, _m: &ModelMeta, _c: &RouteContext) -> Alias { Alias::new("default") }} App::builder() .database("default", primary) // primary / write pool .database("replica", replica) // read-replica pool .router(ReplicaRouter) .build()?;Every read terminal (fetch, first, count, exists, get, …) calls db_for_read; every write terminal (create, bulk_create, update_values, delete, …) calls db_for_write. .on(&pool) is a hard override that bypasses the router entirely.
Read-after-write consistency. get_or_create and update_or_create probe the write pool for their existence check (not the replica), so they can't miss a just-written row on a lagging replica and spuriously insert a duplicate.
See the database router page for the full trait, per-request RouteContext routing, and the schema_for hook. A working end-to-end example lives in examples/read-replica/.
Not yet supported
These are the known gaps - the routing primitive is solid, but a few capabilities that real multi-DB and multitenant setups need aren't built yet:
- No schema-per-tenant or database-per-tenant management layer. The
DatabaseRouterfoundation shipped the routing mechanism (trait +RouteContexttask-local +schema_forSQL qualification), but the management ergonomics are Phase 2: aTenantmodel,migrate_schemas, theSHARED_APPS/public split (which model tables are per-tenant vs shared), and a built-in HTTP tenant-resolver. A custom router can useschema_fortoday, but you provision schemas and seed the resolver yourself. - No dynamic, per-request pool routing for multitenancy.
App::builder().route_context(|req| RouteContext { … })is the seam for a per-request resolver, and the task-local propagates it; what doesn't exist yet is an ambientTenantregistry +migrate_schemasthat makes database-per-tenant a first-class pattern rather than DIY wiring. - No row-level tenancy.
SET LOCAL app.user_id/ Postgres RLS (umbral-rls) is a separate spec; it is not part of the foundation. .on()is SQLite-typed. It accepts a&sqlx::SqlitePool; there's noPgPoolper-query override. For Postgres, rely on model/plugin routing or a custom router.- The
databasessettings map isn't auto-connected - see the callout above.
See also
- Design rationale:
arch.md(plugins own their migrations + database),docs/specs/03-orm-querysets.md. DatabaseRouterfoundation spec:docs/superpowers/specs/2026-06-16-database-router-foundation-design.md.- Working example:
examples/read-replica/. - Open gaps:
planning/gaps2.md#69 Phase 2 (schema/db-per-tenant management, row-level tenancy). (#22 cross-DB FK guard: shipped - see "Cross-database foreign keys" above. #23 read/write split: shipped - see "Read/write (replica) split" above.)