This version is in beta. Some features may change before release.

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.

Code
rust
use umbral::App;
 
// connect (async) - umbral::db::connect picks sqlite vs postgres from the URL
let 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.

Info

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>")]:

Code
rust
#[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():

Code
rust
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):

  1. Explicit per-query override - .on(&pool) on the QuerySet.
  2. Per-model alias - #[umbral(database = "...")] / Model::DATABASE.
  3. Per-plugin default - Plugin::database().
  4. "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:

Code
rust
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 matching settings.database_urlBuildError::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:

Code
rust
#[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.

Code
rust
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 DatabaseRouter foundation shipped the routing mechanism (trait + RouteContext task-local + schema_for SQL qualification), but the management ergonomics are Phase 2: a Tenant model, migrate_schemas, the SHARED_APPS/public split (which model tables are per-tenant vs shared), and a built-in HTTP tenant-resolver. A custom router can use schema_for today, 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 ambient Tenant registry + migrate_schemas that 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 no PgPool per-query override. For Postgres, rely on model/plugin routing or a custom router.
  • The databases settings 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.
  • DatabaseRouter foundation 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.)
ormdatabaseroutingmulti-dbpoolsmultitenancy