SQLite
The default umbral backend - zero-config local development, instant tests, and the same QuerySet API as Postgres.
SQLite is umbral's default backend. The framework starts there because it gives you a working app with no installed services and no connection string to manage - the first cargo run -- migrate creates a file and writes the schema. Production workloads that outgrow SQLite move to Postgres by changing the DATABASE_URL; the model definitions, QuerySet calls, and migration files all carry over unchanged.
When to use it
Reach for SQLite when:
- You're building locally.
cargo runworks with no services to start. - You're running tests. The
sqlite::memory:URL gives every test process a fresh database in microseconds. The pattern: each#[tokio::test]callsumbral::db::connect_sqlite("sqlite::memory:").await?and threads the pool through.on(&pool)so the ambientOnceLockstays untouched between tests. - You're shipping an embedded / single-process app. CLI tools, single-tenant SaaS, internal admin dashboards. SQLite handles meaningful write traffic per process; the concurrency story is "one writer, many readers" at the file level.
Reach for Postgres when you outgrow any of:
- More than one process needs to write concurrently.
- You need Postgres-only field types (arrays, JSONB operators, INET/CIDR/MACADDR, full-text search).
- You want database-enforced per-row security (
umbral-rls). - You want the standard "OLTP at scale" toolchain (logical replication, pgbouncer, point-in-time recovery, etc.).
The migration files and Model impls don't change either way - the move is a DATABASE_URL swap plus a fresh migrate against the new database.
Configure
Three URL forms:
| Form | What it does |
|---|---|
sqlite::memory: | Fresh in-process database that disappears when the connection closes. The default for tests. |
sqlite://path/to/app.db | File-backed. ?mode=rwc (read-write-create) is a common suffix when you want the file auto-created on first connect. |
sqlite: (alias) | Same as sqlite::memory: - kept for the historical shorthand. |
use umbral::prelude::*; #[tokio::main]async fn main() -> Result<(), Box<dyn std::error::Error>> { let settings = Settings::from_env()?; // Default DATABASE_URL is `sqlite::memory:`; override with `sqlite://app.db?mode=rwc` // (or the env var) to get a file-backed pool that survives restarts. 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 sqlite:// but you handed in a PgPool) is a hard BuildError::DatabaseBackendMismatch at boot.
For callers that want a typed SqlitePool without the DbPool enum wrapper (tests that need to call sqlite-specific APIs straight after connecting):
let sqlite_pool: sqlx::SqlitePool = umbral::db::connect_sqlite("sqlite::memory:").await?;Production PRAGMAs
connect_sqlite applies a standard set of production PRAGMAs to every connection in the pool - without them a fresh SqlitePool lands on SQLite's safe-but-slow defaults (journal_mode = DELETE + synchronous = FULL), which cost seconds per concurrent INSERT once any other connection touches the file.
| PRAGMA | Value | Why |
|---|---|---|
journal_mode | WAL | Readers don't block writers; one writer at a time but no full-file lock. Order-of-magnitude faster for concurrent workloads. |
synchronous | NORMAL | Skips the per-commit fsync; safe with WAL since the WAL log is fsynced on checkpoint. |
busy_timeout | 5000ms (5 s) | Wait up to 5 s for a contended writer to release the lock before raising SQLITE_BUSY. |
foreign_keys | ON | SQLite leaves FK enforcement off by default; the ORM emits REFERENCES clauses assuming they're respected. |
Per-statement logging is also turned off (sqlx's default INFO-level logger reads every statement before execution, a measurable per-query overhead under load); the slow statement WARN at the 1-second threshold stays on.
In-memory URLs are backed by a process-unique temp file. A bare sqlite::memory: gives every connection in the pool its OWN private, empty database - a table created on one connection is invisible to a query that lands on another, surfacing as a flaky "no such table". connect_sqlite routes in-memory URLs through a small per-process temp file that every connection sees, sidestepping the issue. File-backed (sqlite://app.db) URLs are untouched.
Pooling
The SQLite pool honours the same UMBRAL_DB_* settings as Postgres - max_connections, min_connections, acquire_timeout, idle_timeout, max_lifetime, and test_before_acquire. SQLite is effectively single-writer (WAL serialises writers behind one lock), so a large max_connections mainly buys concurrent readers, but the knobs are honoured uniformly rather than hardcoding a divergent SQLite path. See Connection pooling for the full list and defaults.
Migrations
cargo run -- makemigrations # writes migrations/<plugin>/0001_…jsoncargo run -- migrate # applies pending migrations against $DATABASE_URLThe engine renders SQLite-appropriate DDL automatically. Differences from the Postgres path:
INTEGER PRIMARY KEY AUTOINCREMENTfori64PKs (vs Postgres'sBIGSERIAL).- Nullability changes route through SQLite's "create-new-table-copy-rename" dance instead of
ALTER COLUMN. SQLite has no in-placeSET NOT NULL; the migration engine hides this. - The same
RETURNING *syntax that drivesManager::createworks (SQLite ≥ 3.35). ON CONFLICT DO NOTHINGworks (SQLite ≥ 3.24).
The migration files are backend-agnostic JSON; the same file applies cleanly to both backends.
Querying
let articles = Article::objects().fetch().await?;The ambient pool reads through whichever DbPool variant App::build() registered. No per-backend code in handler code.
If you hold a SqlitePool directly (test code, scripts outside an App):
let articles = Article::objects().on(&sqlite_pool).fetch().await?;.on(&sqlite_pool) is the explicit-pool override. The cross-backend terminals (.fetch, .first, .get, .count, .exists) all dispatch on the resolved pool variant - the same terminal works on both backends.
inspectdb (SQLite)
inspectdb introspects via sqlite_master and PRAGMA table_info and produces umbral Model definitions for every table in an existing SQLite database:
cargo run -- inspectdb --output src/legacy# -> Wrote src/legacy/models.rs# -> Wrote src/legacy/migrations/app/0001_initial.json--output names a directory; inspectdb writes a models.rs and a 0001_initial.json there. Useful when porting an existing SQLite-backed app onto umbral: the generated structs feed straight into the declare → migrate loop. Foreign-key detection is still deferred (the SQLite path reads columns only, not PRAGMA foreign_key_list). Round-trip integration coverage lives in crates/umbral-core/tests/inspect.rs. See the inspectdb page for the full flag set.
Limits
SQLite doesn't have:
- Native arrays. A
Vec<T>field is Postgres-only; the boot-time system check refuses to start with a clear error pointing at the offending model and field. - HSTORE / JSONB. The
JsonSqlType maps toTEXTon SQLite. The QuerySet's JSON-operator surface usesjson_extract(col, '$.a.b')instead of Postgres's->/->>; the predicate carries both renderings and picks the right one at terminal time. - Full-text search. The
TsVectorfield is Postgres-only. SQLite has FTS5 as a virtual-table extension, but umbral doesn't surface it as a first-class field type at v1. - Native UUIDs. UUID columns land on
TEXTwith the canonical 36-char hex representation. TheUuidRust type still works transparently - sqlx encodes/decodes it. - INET / CIDR / MACADDR. All Postgres-only.
- Row-Level Security.
umbral-rlslogs and skips on SQLite. Use application-layer permissions (umbral-rest's Permissions section).
The cross-backend SqlType catalogue handles the rest - booleans, dates, timestamps, integers, floats, text - with the right native SQLite affinity per column.
Type catalogue
The complete SqlType → SQLite column-type mapping lives in crates/umbral-core/src/backend.rs. Highlights:
SqlType | SQLite column |
|---|---|
SmallInt / Integer / BigInt | SMALLINT / INTEGER / BIGINT (all alias to INTEGER affinity) |
Real / Double | REAL / DOUBLE |
Boolean | BOOLEAN (stored as 0/1, sqlx-decoded as bool) |
Text | TEXT |
Date / Time / Timestamptz | TEXT (ISO 8601, sqlx-decoded as chrono::*) |
Uuid | TEXT (canonical 36-char form) |
Json | TEXT (serde_json::Value round-trips as JSON text) |
See also
- PostgreSQL backend - the production counterpart, including the field types this page lists as missing.
- orm/models - model declaration, the cross-backend
SqlTypecatalogue, and the Postgres-only sections you'll see fail loudly here.