rusqlite — SQLite for Rust

SkillSearch

rusqlite — ergonomic Rust SQLite client with bundled SQLite (no system dep), prepared statements, parameter binding, transactions, custom types via traits, blob I/O, FTS5 full-text search, JSON1 extension, R-Tree spatial indexes, connection pooling (r2d2_sqlite or deadpool-sqlite), and SQLCipher integration via `bundled-sqlcipher` feature. Async wrapper via `tokio-rusqlite` or `sqlx`.

Available today. Use it from your connected AI after setup.

Connect ahel once, and every AI you use reads what you have installed.

Then ask your AI: use the rusqlite — SQLite for Rust skill

What this skill tells your AI

The instructions your AI receives, as published by claude-dev-suite/claude-dev-suite in skills/databases/rusqlite/SKILL.md and read by ahel’s review.

Deep Knowledge: Use mcp__documentation__fetch_docs with technology: rusqlite.

Setup

[dependencies]
rusqlite = { version = "0.32", features = ["bundled"] }

Features:

  • bundled — compile SQLite from source, no system dep (recommended for portability)
  • bundled-sqlcipher — SQLCipher (encrypted SQLite) — see databases/sqlcipher
  • bundled-sqlcipher-vendored-openssl — SQLCipher with vendored OpenSSL
  • chrono, time, uuid, url, serde_json — type integrations
  • blob — blob I/O streaming
  • array — query parameter as array
  • loadable_extension — load runtime SQLite extensions
  • vtab — virtual tables
  • backup — online backup API
  • functions — register custom SQL functions

Quick Start

use rusqlite::{Connection, Result, params};

fn main() -> Result<()> {
    let conn = Connection::open("wallet.db")?;

    conn.execute_batch("
        CREATE TABLE IF NOT EXISTS wallet (
            id TEXT PRIMARY KEY,
            name TEXT NOT NULL,
            balance INTEGER NOT NULL DEFAULT 0,
            created_at INTEGER NOT NULL
        );
        CREATE INDEX IF NOT EXISTS idx_wallet_created ON wallet(created_at);
    ")?;

    conn.execute(
        "INSERT INTO wallet (id, name, balance, created_at) VALUES (?1, ?2, ?3, ?4)",
        params!["abc123", "Main", 100_000, 1735689600i64],
    )?;

    let mut stmt = conn.prepare("SELECT id, name, balance FROM wallet WHERE balance > ?1")?;
    let rows = stmt.query_map([0i64], |row| {
        Ok(Wallet {
            id: row.get(0)?,
            name: row.get(1)?,
            balance: row.get(2)?,
        })
    })?;

    for wallet in rows {
        println!("{:?}", wallet?);
    }

    Ok(())
}

#[derive(Debug)]
struct Wallet {
    id: String,
    name: String,
    balance: i64,
}

Connection Patterns

// In-memory (great for tests)
let conn = Connection::open_in_memory()?;

// File path
let conn = Connection::open("data.db")?;

// With flags
use rusqlite::OpenFlags;
let conn = Connection::open_with_flags(
    "data.db",
    OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_NO_MUTEX,
)?;

// Pragmas after open
conn.pragma_update(None, "journal_mode", "WAL")?;
conn.pragma_update(None, "synchronous", "NORMAL")?;
conn.pragma_update(None, "cache_size", -65536)?;        // 64MB cache
conn.pragma_update(None, "foreign_keys", "ON")?;
conn.pragma_update(None, "temp_store", "MEMORY")?;

For wallet apps, WAL mode is essential — concurrent reads + single writer without blocking.

Parameter Binding

// Positional
conn.execute("INSERT INTO t (a, b) VALUES (?1, ?2)", params![1, "hello"])?;

// Named
use rusqlite::named_params;
conn.execute(
    "INSERT INTO t (a, b) VALUES (:a, :b)",
    named_params! { ":a": 1, ":b": "hello" },
)?;

// Single param shortcut
conn.execute("DELETE FROM t WHERE id = ?", [42])?;

Always use parameters, never string-format SQL — SQL injection vulnerable.

Querying

// Single row, single column
let count: i64 = conn.query_row(
    "SELECT count(*) FROM wallet",
    [],
    |row| row.get(0),
)?;

// Single row, multiple columns
let (id, name): (String, String) = conn.query_row(
    "SELECT id, name FROM wallet WHERE id = ?",
    ["abc"],
    |row| Ok((row.get(0)?, row.get(1)?)),
)?;

// Optional row (handles "no rows")
use rusqlite::OptionalExtension;
let maybe_wallet: Option<Wallet> = conn
    .query_row(
        "SELECT id, name, balance FROM wallet WHERE id = ?",
        ["abc"],
        |row| Ok(Wallet {
            id: row.get(0)?,
            name: row.get(1)?,
            balance: row.get(2)?,
        }),
    )
    .optional()?;

// Iterate rows
let mut stmt = conn.prepare("SELECT id FROM wallet ORDER BY created_at")?;
let ids: Vec<String> = stmt
    .query_map([], |row| row.get(0))?
    .collect::<Result<Vec<_>, _>>()?;

Transactions

let tx = conn.transaction()?;
tx.execute("UPDATE wallet SET balance = balance - ? WHERE id = ?",
    params![1000, "from_id"])?;
tx.execute("UPDATE wallet SET balance = balance + ? WHERE id = ?",
    params![1000, "to_id"])?;
tx.commit()?;                                     // OR tx.rollback()? OR drop (auto-rollback)

For deferred transactions:

use rusqlite::TransactionBehavior;
let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?;
// IMMEDIATE locks DB for write at tx start (vs DEFERRED at first write)

For wallet apps: use IMMEDIATE for transfer flows to avoid SQLITE_BUSY mid-transaction.

Custom Types — ToSql / FromSql

For domain types stored as native SQLite types (TEXT, INTEGER, BLOB):

use rusqlite::types::{FromSql, FromSqlError, FromSqlResult, ToSql, ToSqlOutput, ValueRef};
use rusqlite::Result;

#[derive(Debug, Clone, PartialEq, Eq, Hash)]
struct Address(String);

impl ToSql for Address {
    fn to_sql(&self) -> Result<ToSqlOutput<'_>> {
        Ok(ToSqlOutput::from(self.0.as_str()))
    }
}

impl FromSql for Address {
    fn column_result(value: ValueRef<'_>) -> FromSqlResult<Self> {
        let s = value.as_str()?;
        if !s.starts_with("bc1") {
            return Err(FromSqlError::InvalidType);
        }
        Ok(Address(s.to_string()))
    }
}

// Use directly
let addr: Address = conn.query_row(
    "SELECT address FROM utxo WHERE txid = ?",
    ["abc"],
    |row| row.get(0),
)?;

For enums:

#[derive(Debug, Clone, Copy)]
enum NetworkKind { Bitcoin, Testnet, Signet, Regtest }

impl ToSql for NetworkKind {
    fn to_sql(&self) -> Result<ToSqlOutput<'_>> {
        let s = match self {
            NetworkKind::Bitcoin => "bitcoin",
            NetworkKind::Testnet => "testnet",
            NetworkKind::Signet => "signet",
            NetworkKind::Regtest => "regtest",
        };
        Ok(ToSqlOutput::from(s))
    }
}

impl FromSql for NetworkKind {
    fn column_result(value: ValueRef<'_>) -> FromSqlResult<Self> {
        match value.as_str()? {
            "bitcoin" => Ok(NetworkKind::Bitcoin),
            "testnet" => Ok(NetworkKind::Testnet),
            "signet" => Ok(NetworkKind::Signet),
            "regtest" => Ok(NetworkKind::Regtest),
            _ => Err(FromSqlError::InvalidType),
        }
    }
}

JSON1 Support

// Stores JSON as TEXT, queryable via SQLite JSON1
conn.execute("CREATE TABLE labels (txid TEXT PRIMARY KEY, data TEXT)", [])?;

let label = serde_json::json!({
    "type": "tx",
    "label": "Coffee",
    "category": "food",
});

conn.execute(
    "INSERT INTO labels VALUES (?1, ?2)",
    params!["abc...", label.to_string()],
)?;

// Query via json_extract
let category: String = conn.query_row(
    "SELECT json_extract(data, '$.category') FROM labels WHERE txid = ?",
    ["abc..."],
    |row| row.get(0),
)?;

For BIP329 wallet labels: this is the perfect storage pattern.

Blob I/O (Streaming Large Binary)

use std::io::{Read, Write};

// Insert blob with size, then stream
conn.execute(
    "INSERT INTO files (name, content) VALUES (?, ZEROBLOB(?))",
    params!["doc.pdf", 1024 * 1024],          // 1 MB blob
)?;

let rowid = conn.last_insert_rowid();

// Open for write
let mut blob = conn.blob_open(rusqlite::DatabaseName::Main, "files", "content", rowid, false)?;
blob.write_all(&data)?;
drop(blob);                                    // close

// Open for read
let mut blob = conn.blob_open(rusqlite::DatabaseName::Main, "files", "content", rowid, true)?;
let mut buf = vec![0u8; 4096];
loop {
    let n = blob.read(&mut buf)?;
    if n == 0 { break; }
    process(&buf[..n]);
}

FTS5 Full-Text Search

conn.execute_batch("
    CREATE VIRTUAL TABLE tx_search USING fts5(
        txid, label, notes, tokenize='porter ascii'
    );
")?;

conn.execute(
    "INSERT INTO tx_search (txid, label, notes) VALUES (?, ?, ?)",
    params!["abc", "Coffee at Starbucks", "Morning latte"],
)?;

// Search
let mut stmt = conn.prepare("SELECT txid, label FROM tx_search WHERE tx_search MATCH ?")?;
let results = stmt
    .query_map(["coffee"], |row| {
        Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
    })?
    .collect::<Result<Vec<_>, _>>()?;

Migrations

Use rusqlite_migration (or refinery):

rusqlite_migration = "1.3"
use rusqlite_migration::{Migrations, M};

let migrations = Migrations::new(vec![
    M::up("CREATE TABLE wallet (id TEXT PRIMARY KEY, name TEXT)"),
    M::up("ALTER TABLE wallet ADD COLUMN balance INTEGER DEFAULT 0"),
    M::up("CREATE INDEX idx_balance ON wallet(balance)"),
]);

migrations.to_latest(&mut conn)?;

For production: keep migrations as separate .sql files, version-controlled.

Connection Pooling

For multi-threaded access (each thread needs own Connection — Connection is Send not Sync):

r2d2 = "0.8"
r2d2_sqlite = "0.25"
use r2d2::Pool;
use r2d2_sqlite::SqliteConnectionManager;

let manager = SqliteConnectionManager::file("wallet.db")
    .with_init(|c| {
        c.execute_batch("PRAGMA journal_mode=WAL; PRAGMA foreign_keys=ON;")
    });

let pool = Pool::builder()
    .max_size(15)
    .build(manager)?;

// Per-task
let conn = pool.get()?;
let count: i64 = conn.query_row("SELECT count(*) FROM wallet", [], |row| row.get(0))?;

Async (tokio-rusqlite)

rusqlite is sync — wrapping in tokio-rusqlite keeps connection on a dedicated thread:

tokio-rusqlite = "0.6"
use tokio_rusqlite::Connection;

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    let conn = Connection::open("wallet.db").await?;

    let count = conn.call(|conn| {
        let n: i64 = conn.query_row("SELECT count(*) FROM wallet", [], |row| row.get(0))?;
        Ok(n)
    }).await?;

    println!("Count: {}", count);
    Ok(())
}

Each query runs on the dedicated thread — no async/await contention with the SQLite C API.

For full async ORM: use sqlx with sqlite driver (separate skill).

SQLCipher Integration

[dependencies]
rusqlite = { version = "0.32", features = ["bundled-sqlcipher"] }
let conn = Connection::open("wallet.db")?;
let key_hex: String = key_bytes.iter().map(|b| format!("{:02x}", b)).collect();
conn.pragma_update(None, "key", format!("x'{}'", key_hex))?;

// Verify key
conn.query_row("SELECT count(*) FROM sqlite_master", [], |_| Ok(()))?;

// Tune
conn.pragma_update(None, "journal_mode", "WAL")?;

See databases/sqlcipher for full SQLCipher details.

Custom SQL Functions (Rust → SQL)

features = ["functions"]
use rusqlite::functions::FunctionFlags;

conn.create_scalar_function(
    "btc_to_sats",
    1,
    FunctionFlags::SQLITE_UTF8 | FunctionFlags::SQLITE_DETERMINISTIC,
    |ctx| {
        let btc: f64 = ctx.get(0)?;
        Ok((btc * 100_000_000.0) as i64)
    },
)?;

let sats: i64 = conn.query_row(
    "SELECT btc_to_sats(0.001)",
    [],
    |row| row.get(0),
)?;
assert_eq!(sats, 100_000);

Backup API

features = ["backup"]
use rusqlite::backup::Backup;
use std::time::Duration;

let src = Connection::open("wallet.db")?;
let mut dst = Connection::open("wallet_backup.db")?;
let backup = Backup::new(&src, &mut dst)?;
backup.run_to_completion(5, Duration::from_millis(250), None)?;

For SQLCipher: backup target needs its own PRAGMA key after attach.

Testing

#[cfg(test)]
mod tests {
    use super::*;
    use rusqlite::Connection;

    fn test_db() -> Connection {
        let conn = Connection::open_in_memory().unwrap();
        conn.execute_batch(include_str!("schema.sql")).unwrap();
        conn
    }

    #[test]
    fn test_insert_wallet() {
        let conn = test_db();
        conn.execute(
            "INSERT INTO wallet (id, name) VALUES (?, ?)",
            params!["1", "Main"],
        ).unwrap();

        let count: i64 = conn.query_row("SELECT count(*) FROM wallet", [], |r| r.get(0)).unwrap();
        assert_eq!(count, 1);
    }
}

Mobile / Cross-Compile

bundled feature requires C compiler for cross-compile target. cargo-ndk handles this:

# Android
cargo ndk -t arm64-v8a -o jniLibs build --release

# iOS
cargo build --release --target aarch64-apple-ios

For SQLCipher mobile: use bundled-sqlcipher-vendored-openssl to avoid system OpenSSL hassles.

Performance Tips

TipWhy
PRAGMA journal_mode=WALConcurrent reads, faster writes
PRAGMA synchronous=NORMALFaster (still durable enough with WAL)
PRAGMA cache_size=-6553664MB cache (negative = KB)
Use prepared statements (prepare_cached) for repeated queriesSkip SQL parsing each call
Wrap bulk inserts in transaction100x speedup for many INSERTs
Use WITHOUT ROWID for tables with non-INTEGER PKSmaller, faster
Index columns used in WHERE/ORDER BYAvoid full table scan
Use EXPLAIN QUERY PLAN to verify index usageDebug slow queries
Use INTEGER for sat amounts (not REAL/TEXT)Exact arithmetic, no float drift
PRAGMA optimize periodicallyUpdates query planner stats

Anti-Patterns

Anti-patternWhy it's badCorrect approach
Concatenating SQL stringsInjection riskUse params!
f64 for sat amountsFloat precision lostUse i64 (sats fit easily)
Single Connection across threadsNot SyncPool with r2d2_sqlite
Connection per querySlow, no PRAGMA persistenceReuse connection
Missing transaction for bulk insert100x slowerWrap in transaction()
Forgetting WAL for mobile/multi-readerSQLITE_BUSY errorsAlways WAL for app DB
Storing large blobs in main rowBloats every readUse blob I/O streaming
prepare() in tight loopRe-parses SQL each callprepare_cached()
Custom enums as TEXT without FromSqlStringly-typedImplement ToSql/FromSql
Not handling SQLITE_BUSY in concurrent appRandom failuresSet busy_timeout or use immediate transactions

Troubleshooting

SymptomCauseFix
database is lockedMulti-writer or long readWAL + busy_timeout; reduce tx duration
Slow insertsNo transactionWrap in transaction()
no such function: json_extractJSON1 not enabledBuilt-in since SQLite 3.38; use bundled feature for new-enough version
Cross-compile fails (libsqlite3-sys)C compiler missing for targetUse cargo-ndk or cross
iOS app size +500KB after addbundled SQLite sizeAcceptable; native SQLite alternative is smaller but version uncertain
OptionalExtension not foundForgot use rusqlite::OptionalExtensionImport the trait
params![] macro not foundForgot importuse rusqlite::params
Custom function called too oftenNot marked DETERMINISTICAdd FunctionFlags::SQLITE_DETERMINISTIC
Result<_, rusqlite::Error> everywhereNo ? operatorUse anyhow::Result or convert
Connection drop hangsPool configTune Pool::builder().connection_timeout(...)

When NOT to Use This Skill

ScenarioUse Instead
Encrypted SQLite (SQLCipher)databases/sqlcipher
Server PostgreSQL/MySQLrespective skills
ORM (Diesel, SeaORM)ORM-specific
Async-native ORMsqlx
KMP shared DBmobile/kotlin-multiplatform (SQLDelight)
Pure SQL knowledgedatabases/sql-fundamentals

Signals

GitHub stars
33
Forks
6
Last commit
Sep 2026
Advanced
Catalog kind
skill
Gateway key
rusqlite
Source
github.com/claude-dev-suite/claude-dev-suite