PostgreSQL
Point an umbral app at Postgres - connection URL, migrations, queries, and the Postgres-only field types.
PostgreSQL is umbral's first-class production backend. The plain Article::objects().fetch() you write against SQLite runs unchanged against Postgres; the migration engine emits Postgres-flavoured DDL; and a set of Postgres-only field types (arrays, INET/CIDR/MACADDR, JSONB operators, full-text search) opens up the engine where the workload calls for it.
Configure
Point DATABASE_URL (env or umbral.toml) at a postgres:// (or postgresql://) URL. umbral::db::connect dispatches on the URL scheme and returns the matching [DbPool] variant.
use umbral::prelude::*; #[tokio::main]async fn main() -> Result<(), Box<dyn std::error::Error>> { let settings = Settings::from_env()?; let pool = umbral::db::connect(&settings.database_url).await?; let app = App::builder() .settings(settings) .database("default", pool) .plugin(blog::BlogPlugin) .build()?; app.serve("127.0.0.1:8000".parse::<std::net::SocketAddr>()?).await?; Ok(())}App::build() cross-checks settings.database_url against the pool's runtime backend. A mismatch (URL says postgres:// but the pool is DbPool::Sqlite, or vice versa) is a hard BuildError::DatabaseBackendMismatch at boot, not a silent failure on the first query.
Pooling. connect returns a sqlx PgPool wrapped in the DbPool enum, with umbral's pool configuration already applied - max_connections, min_connections, a bounded acquire_timeout (so a saturated pool fails fast instead of blocking forever), idle_timeout, max_lifetime, and test_before_acquire. The pool is process-wide, shared between every handler, and emits one boot-log line with its effective configuration.
When the UMBRAL_DB_* knobs actually apply. connect reads the pool knobs from the ambient settings, which are only published by App::build(). In the canonical boot sequence the default pool is opened before build() (connect(&settings.database_url) → .database("default", pool) → .build()), so at that point no ambient settings exist and the pool uses the built-in defaults (max_connections = 10, acquire_timeout = 30s, min_connections = 0, idle_timeout = 600s, max_lifetime = 1800s, test_before_acquire = true) - UMBRAL_DB_* values are not applied to it. Pools opened after build() (e.g. runtime tenant pools) do honour the settings. To size the default pool today, build it with sqlx's own options from your loaded Settings and hand it to the builder - .database() accepts a plain PgPool:
let settings = Settings::from_env()?;let pool = sqlx::postgres::PgPoolOptions::new() .max_connections(settings.db_max_connections) .min_connections(settings.db_min_connections) .acquire_timeout(std::time::Duration::from_secs(settings.db_acquire_timeout_secs)) .connect(&settings.database_url) .await?;let app = App::builder() .settings(settings) .database("default", pool) .build()?;Verify the effective values in the umbral: opening database pool boot-log line. See Connection pooling for the full knob list.
Migrations
cargo run -- makemigrations # writes a JSON migration under migrations/<plugin>/cargo run -- migrate # applies pending migrations against $DATABASE_URLThe engine renders Postgres DDL automatically:
BIGSERIALfori64primary keys (vsINTEGER PRIMARY KEY AUTOINCREMENTon SQLite).ALTER TABLE ... ALTER COLUMN SET / DROP NOT NULLfor nullability changes (vs SQLite's table-recreation dance).$1..$Nplaceholders andON CONFLICT DO NOTHINGon the migration apply path.- Native types from the Type catalogue below -
JSONB,UUID,TIMESTAMPTZ, arrays, INET/CIDR/MACADDR, TSVECTOR - instead of theTEXT/BLOBfallbacks SQLite uses.
The migration files themselves are backend-agnostic JSON; the same file applies to both backends. The engine renders the backend-specific SQL at migrate time from the active pool's backend.
Querying
let articles = Article::objects().fetch().await?;The ambient pool() reads through whichever DbPool variant App::build() registered. No per-backend code in the handler.
If you hold a PgPool directly (integration tests, scripts that don't run through App::build()):
let articles = Article::objects().on_pg(&pg_pool).fetch().await?;.on_pg(&pool) is the Postgres counterpart of .on(&sqlite_pool). The _pg variants on the QuerySet (fetch_pg, first_pg, get_pg, count_pg, exists_pg) take an explicit PgPool and skip the dispatch entirely - useful for models whose fields are Postgres-only and so don't satisfy the dual FromRow bound.
Postgres-only field types
Some workloads benefit enough from native PG features to make the SQLite story irrelevant. Umbral exposes them as first-class field types; the boot-time system check refuses to start if you register one against a SQLite pool, with a clear error pointing at the offending model.
| Type | Rust field | Postgres column | Notes |
|---|---|---|---|
| Array | Vec<T> / Option<Vec<T>> | T[] | Element type is one of i32, i64, String, Uuid, bool. See orm/models - Array columns. |
| JSONB | serde_json::Value (M3 Json SqlType) | JSONB | Indexed ->, ->> operator surface; the QuerySet exposes them as .has_key("key") and .path_text(&["a", "b"]).eq("v"). See orm/querying - JSON column ops. |
| INET / CIDR / MACADDR | IpNet-style newtypes | INET, CIDR, MACADDR | Networking fields. orm/models - Network address columns. |
| TSVECTOR | umbral::orm::TsVector | TSVECTOR | Full-text search. Populate via trigger or GENERATED ALWAYS AS (to_tsvector(...)). orm/models - Full-text search columns. |
Plus the standard cross-backend types (Boolean, Uuid, Timestamptz, etc.) all map to their native Postgres equivalents (BOOLEAN, UUID, TIMESTAMP WITH TIME ZONE) on this backend.
inspectdb (Postgres)
inspectdb walks pg_catalog and information_schema to produce umbral Model definitions for every table in an existing database:
cargo run -- inspectdb --output src/legacy# -> Wrote src/legacy/models.rs# -> Wrote src/legacy/migrations/app/0001_initial.json--output is a directory: inspectdb writes a models.rs with one #[derive(Model)] struct per table plus a 0001_initial.json migration, ready to drop into a module and register from a plugin (or via .model::<T>()). Round-trip integration coverage lives in crates/umbral-core/tests/postgres_inspect.rs. See the inspectdb page for the full flag set (--mark-applied, adopting a populated DB).
Row-Level Security
Postgres's Row-Level Security is the right tool for per-row access control that the database enforces regardless of how the row is queried. The umbral-rls plugin (Row-Level Security (umbral-rls)) wraps the CREATE POLICY shape and runs at Plugin::on_ready time. On a SQLite backend the plugin logs and skips, so RLS-using apps stay dev-portable.
Type catalogue
The complete SqlType → Postgres column-type mapping lives in crates/umbral-core/src/backend.rs. Highlights:
SqlType | Postgres column |
|---|---|
SmallInt / Integer / BigInt | SMALLINT / INTEGER / BIGINT |
Real / Double | FLOAT / DOUBLE PRECISION |
Boolean | BOOLEAN |
Text | TEXT |
Date / Time / Timestamptz | DATE / TIME / TIMESTAMP WITH TIME ZONE |
Uuid | UUID |
Json | JSONB (always JSONB, never JSON - the operator + index story is the whole point) |
Array(T) | T[] (recursively mapped) |
Inet / Cidr / MacAddr | INET / CIDR / MACADDR |
FullText | TSVECTOR (via ColumnType::custom) |
See also
- SQLite backend - the dev / test counterpart.
- Row-Level Security (umbral-rls) - Postgres-only policy plugin.
- orm/models - Postgres-only column types - array / JSON / network / full-text field declarations and operator surface.