04 · Databases with rusqlite¶
rusqlite gives Rust a thin, typed wrapper over SQLite — no async runtime,
no server process, just a .db file (or :memory:) and a connection.
It's the fastest way to give a CLI tool or small service real persistence,
and it's a good place to see how Rust turns "the query might fail" and "the
row might not have the shape you expect" into values instead of exceptions.
Setup¶
bundled compiles SQLite itself into your binary instead of requiring the
system to have libsqlite3 installed — slower first build, zero runtime
dependency problems. Drop the feature if you know the target already has
SQLite.
Schema, insert, query¶
use rusqlite::{Connection, Result, params};
#[derive(Debug)]
struct Task {
id: i64,
title: String,
done: bool,
}
fn main() -> Result<()> {
let conn = Connection::open_in_memory()?;
conn.execute(
"CREATE TABLE tasks (
id INTEGER PRIMARY KEY,
title TEXT NOT NULL,
done INTEGER NOT NULL DEFAULT 0
)",
(),
)?;
conn.execute(
"INSERT INTO tasks (title, done) VALUES (?1, ?2)",
params!["write docs", false],
)?;
conn.execute(
"INSERT INTO tasks (title, done) VALUES (?1, ?2)",
params!["ship release", true],
)?;
let mut stmt = conn.prepare("SELECT id, title, done FROM tasks ORDER BY id")?;
let rows = stmt.query_map([], |row| {
Ok(Task {
id: row.get(0)?,
title: row.get(1)?,
done: row.get::<_, i64>(2)? != 0,
})
})?;
for task in rows {
let task = task?;
println!("{:?}", task);
}
Ok(())
}
main() -> Result<()> plus ? on every fallible call is the idiomatic
shape for a small program that talks to a database: any rusqlite::Error
(malformed SQL, a broken connection, a constraint violation) short-circuits
out of main and prints via Debug, instead of you writing .unwrap()
everywhere and panicking on the first bad row.
row.get::<_, i64>(2)? is an explicit turbofish because SQLite has no
native boolean type — it stores 0/1 as an integer, so you pull an i64
out and convert. This is a small but real trap: row.get::<_, bool>(2)
compiles and even works on some SQLite builds, but relying on it is fragile
across schema versions that stored booleans differently.
Failure is a value, not an exception¶
let bad = conn.execute("INSERT INTO tasks (title) VALUES (NULL)", ());
match bad {
Ok(_) => println!("unexpectedly succeeded"),
Err(e) => println!("insert failed as expected: {e}"),
}
The NOT NULL constraint lives in the schema, SQLite enforces it, and
rusqlite surfaces the failure as Err(rusqlite::Error::SqliteFailure(...))
with a human-readable message via Display. Nothing unwound the stack —
you get to decide whether that's a fatal error, a retry, or a message back
to a caller.
Rust-specific traps¶
Connection is not Sync. A single rusqlite::Connection cannot be
shared across threads by reference; SQLite's C API isn't safe for
concurrent use from one handle. The common fix is Arc<Mutex<Connection>>,
or better, a connection pool (r2d2_sqlite) that hands each thread its own
connection.
Borrowed rows don't outlive the statement. stmt.query_map returns an
iterator borrowing from stmt; you cannot return that iterator from a
function while dropping stmt, and the borrow checker will say so plainly.
Collect into a Vec<Task> before the statement goes out of scope if the
rows need to escape the function.
params! macro vs. tuples. conn.execute(sql, (a, b)) and
conn.execute(sql, params![a, b]) look interchangeable but differ once
types get involved — params! will happily coerce a &str and a String
side by side, plain tuples are pickier about matching ToSql impls
consistently. When a query's parameter types are mixed, reach for params!
first.
Named vs. positional placeholders. Mixing ?1, ?2 (positional) with
:name (named) placeholders in the same statement is legal SQL but a
frequent source of "wrong value in wrong column" bugs that the compiler
cannot catch — SQLite only tells you at query-prepare time, and only if the
name doesn't match at all.
How It Actually Works¶
Connection's lack of Sync isn't a rusqlite design choice, it's an FFI
fact propagating up through the type system: rusqlite::Connection wraps a
raw pointer to a sqlite3* C struct, and the SQLite C library documents
that a single connection handle used without its own internal mutex is not
safe for concurrent access from multiple threads. Because a raw pointer has
no Sync impl by default, and Rust auto-derives Send/Sync structurally
(as covered in Module 1's concurrency chapter), Connection correctly
inherits "not safely shareable" from the FFI boundary — the compiler is
faithfully reporting a real constraint of the C library underneath, not an
arbitrary restriction. Arc<Mutex<Connection>> is the direct fix: Arc
supplies safe shared ownership across threads and Mutex serializes actual
access to the connection, satisfying SQLite's real requirement instead of
working around a compiler opinion.
The borrowed-row restriction is ordinary lifetime tracking, not a rusqlite
quirk: stmt.query_map returns a Rows/MappedRows iterator that holds an
internal reference into the still-live prepared statement's cursor state
inside SQLite — the iterator's lifetime parameter is tied to &stmt, so the
borrow checker treats returning it past stmt's scope exactly like
returning a reference to a local variable in Module 2's lifetime rules.
.collect::<Vec<Task>>() converts each borrowed row into a fully owned
Task while the statement is still alive, breaking the borrow and letting
the resulting Vec (which owns its data outright) escape the function
freely.
Cheat sheet¶
| Call | Purpose |
|---|---|
Connection::open("file.db") |
Open (creating if absent) a database file |
Connection::open_in_memory() |
Ephemeral :memory: database, useful for tests |
conn.execute(sql, params) |
Run INSERT/UPDATE/DELETE/DDL, returns rows affected |
conn.prepare(sql) |
Compile a statement for reuse or row iteration |
stmt.query_map(params, closure) |
Row iterator, closure maps each row to a value |
row.get::<_, T>(i) |
Typed column extraction by index, fallible |
params![a, b, ...] |
Macro for building a heterogeneous parameter list |
Exercise¶
Add an UPDATE tasks SET done = 1 WHERE id = ?1 call that marks task 1
done, then re-run the SELECT query and confirm the printed Task struct
shows done: true. Then try inserting a task with an id that already
exists (INSERT INTO tasks (id, title) VALUES (1, 'dup')) and print the
rusqlite::Error you get back — identify which SQLite constraint it names.