Chapter 12 · the embedded database
If you've used Microsoft.Data.Sqlite or EF Core against SQLite, the shape is familiar. This chapter covers the crates, the ADO.NET-style basics, and the handful of settings (WAL, a busy timeout, transactions, pooling) that separate a SQLite app that scales from one that throws "database is locked."
There's no built-in data layer; you choose one, the way you'd pick between raw ADO.NET, Dapper, and EF Core.
| Crate | Style | .NET feeling |
|---|---|---|
rusqlite | synchronous, thin wrapper over the C library | Microsoft.Data.Sqlite / ADO.NET |
sqlx | async, queries checked against the DB at compile time | Dapper, but compile-time-verified |
diesel / sea-orm | full ORM with a query DSL | EF Core |
This chapter uses rusqlite for the examples because it's the most direct and the concepts transfer; the best practices at the end apply whichever you pick.
Open a connection, run statements, read rows. Note the row-mapping closure: it's the manual equivalent of an EF projection or a Dapper map.
use rusqlite::{Connection, params};
let conn = Connection::open("mail.db")?;
conn.execute(
"CREATE TABLE IF NOT EXISTS users (id INTEGER PRIMARY KEY, name TEXT NOT NULL)",
[],
)?;
conn.execute("INSERT INTO users (name) VALUES (?1)", params![name])?;
let mut stmt = conn.prepare("SELECT id, name FROM users WHERE id = ?1")?;
let user = stmt.query_row(params![id], |row| {
Ok(User { id: row.get(0)?, name: row.get(1)? })
})?;
The single most important rule, identical to .NET: pass values as parameters (?1, or named :name), never by building a SQL string. String interpolation is a SQL-injection hole and defeats statement caching.
// string-built SQL: injectable
var sql = $"SELECT * FROM users " +
$"WHERE name = '{name}'";
// value is bound, never concatenated
conn.query_row(
"SELECT id FROM users WHERE name = ?1",
params![name], |r| r.get(0))?;
Wrap related writes in a transaction. Beyond all-or-nothing semantics (like a SqlTransaction / TransactionScope), it's a massive throughput win: without one, SQLite commits — and fsyncs — after every statement. A thousand inserts inside one transaction can be orders of magnitude faster than a thousand bare inserts.
let tx = conn.transaction()?;
for u in &users {
tx.execute("INSERT INTO users (name) VALUES (?1)", params![u.name])?;
}
tx.commit()?; // dropping it without commit rolls back — RAII again
SQLite's defaults are conservative. A few PRAGMA settings, applied once per connection at open time, are what most "best practices" guides are really about.
conn.execute_batch("
PRAGMA journal_mode = WAL; -- readers don't block the writer
PRAGMA synchronous = NORMAL; -- safe with WAL, much faster than FULL
PRAGMA foreign_keys = ON; -- enforce FKs (off by default!)
PRAGMA busy_timeout = 5000; -- wait 5s on a locked db, don't fail instantly
")?;
| Pragma | Why |
|---|---|
journal_mode = WAL | Write-Ahead Logging lets many readers run concurrently with one writer, instead of readers and the writer blocking each other. The biggest single concurrency win. |
busy_timeout | SQLite allows only one writer at a time; without a timeout, a second writer fails immediately with "database is locked." A timeout makes it wait and retry. |
synchronous = NORMAL | With WAL this is durable across app crashes and far faster than the FULL default. |
foreign_keys = ON | Foreign-key enforcement is off by default in SQLite — surprising if you come from SQL Server. |
A rusqlite::Connection isn't Sync — you can't share one across threads (the compiler enforces this; ch. 2). The standard answer is a pool: r2d2 with r2d2_sqlite, the role a DbContext-per-request or an ADO.NET connection pool plays.
use r2d2_sqlite::SqliteConnectionManager;
let manager = SqliteConnectionManager::file("mail.db");
let pool = r2d2::Pool::new(manager)?;
// per request / per task: check one out, use it, return it on drop
let conn = pool.get()?;
conn.execute("INSERT INTO users (name) VALUES (?1)", params![name])?;
Almost every SQLite scaling complaint traces to the same root: default rollback-journal mode + no busy timeout + many writers. The fix is WAL plus a busy_timeout (12.5), and accepting that writes still serialize — SQLite is one-writer-at-a-time by design. If your workload is write-heavy and highly concurrent, that's the signal you've outgrown SQLite, not a setting to tune away.
For evolving the schema over releases — the EF Core Migrations equivalent — use rusqlite_migration or refinery with rusqlite, or sqlx migrate if you're on sqlx. Each tracks which versioned migrations have run and applies the rest at startup.
WAL + busy_timeout + foreign_keys = ON at open; parameterized queries always; batch writes in transactions; prepare/cache statements you reuse; a connection pool rather than a shared connection; and migrations for schema changes. Get those right and SQLite comfortably handles a surprising amount — it's the same engine behind countless production apps, not just a toy.