How to Use SQLite with Rust

Complete Guide

Embed SQLite in Rust by enabling the sqlite and libsqlite3-sys/bundled features in the diesel crate.

When a server feels like overkill

You are building a desktop app, a CLI tool, or a local data processor. You need persistent storage. Spinning up PostgreSQL or MySQL feels like bringing a firehose to put out a campfire. You want a single file. You want zero configuration. You want the database to travel with your binary.

SQLite is the standard answer. It is a C library that lives inside a single .so or .dll file, or gets statically linked directly into your executable. Rust does not speak C by default. You need a bridge. The Rust ecosystem offers several bridges, and picking the right one determines whether your build process is smooth or a debugging marathon.

The bridge between Rust and C

Rust and C live in different memory worlds. Rust enforces ownership, lifetimes, and strict aliasing rules at compile time. C relies on manual memory management and raw pointers. When you call a C library from Rust, you are crossing a safety boundary. The compiler cannot verify that the C code respects Rust's guarantees. That is why every C binding requires an unsafe block at the boundary, or a carefully audited safe wrapper that hides the unsafe behind a public API.

SQLite bindings in Rust fall into two categories. Low-level wrappers like rusqlite expose the C API with minimal abstraction. They give you direct control over statements, parameters, and result rows. High-level ORMs like diesel or sqlx add type-safe query building, schema migrations, and struct mapping. They compile your Rust types into SQL and map rows back into Rust structs.

The bundled feature changes how the C library itself arrives on your system. Without it, the Rust crate expects to find libsqlite3 already installed on your machine via apt, brew, or pacman. With it, the crate downloads the official SQLite C source, compiles it with cc, and links it directly into your binary. You ship one file. The database engine travels with you.

Convention aside: the Rust community strongly prefers libsqlite3-sys/bundled for distributable applications. System libraries drift. A user on Ubuntu 20.04 might have SQLite 3.31, while your code was tested against 3.42. Bundling eliminates version mismatch bugs before they reach production.

Your first bundled setup

Add the dependency to your Cargo.toml. The sqlite feature tells diesel to generate SQLite-specific query builders. The libsqlite3-sys/bundled feature tells the underlying C wrapper to compile SQLite from source.

[dependencies]
diesel = { version = "2.2", features = ["sqlite", "libsqlite3-sys/bundled"] }

Create a minimal main.rs that opens a connection, creates a table, inserts a row, and reads it back.

use diesel::prelude::*;
use diesel::sqlite::SqliteConnection;

/// Open a database, write a row, read it back, and print the result.
fn main() -> Result<(), Box<dyn std::error::Error>> {
    // Connect to a file on disk. Creates it if missing.
    let mut conn = SqliteConnection::establish("app.db")?;

    // Create a simple table if it does not already exist.
    diesel::sql_query(
        "CREATE TABLE IF NOT EXISTS notes (id INTEGER PRIMARY KEY, text TEXT NOT NULL)"
    ).execute(&mut conn)?;

    // Insert a new note using parameterized SQL to prevent injection.
    diesel::sql_query("INSERT INTO notes (text) VALUES (?)")
        .bind::<diesel::sql_types::Text, _>("Hello from Rust")
        .execute(&mut conn)?;

    // Fetch the first row and map it to a tuple.
    let results: Vec<(String,)> = diesel::sql_query("SELECT text FROM notes LIMIT 1")
        .load(&mut conn)?;

    // Print the retrieved value.
    println!("Stored note: {}", results[0].0);
    Ok(())
}

Run cargo run. The first build takes a moment. The second build is instant. The binary now contains a working SQLite engine.

What actually happens during the build

When you run cargo build, cargo reads your Cargo.toml and fetches diesel along with its dependency tree. diesel depends on diesel_derives for macro expansion, diesel_table_macro_syntax for table definitions, and libsqlite3-sys for the C bindings.

The libsqlite3-sys/bundled feature triggers a build script. The script downloads the official SQLite amalgamation source from sqlite.org. It extracts the .c and .h files into a temporary directory. The cc crate compiles those C files into object files, applying the correct compiler flags for your target architecture. The linker then stitches those object files into your final executable.

At runtime, your binary does not search the filesystem for libsqlite3.so. The symbols are already baked into the executable. When you call SqliteConnection::establish, the Rust wrapper allocates a sqlite3 struct via FFI, initializes the file handle, and returns a safe Rust connection object. Every query you run passes through the wrapper, which translates Rust types into SQLite's C API and maps the results back.

The compiler guarantees that the connection is dropped when it goes out of scope. The drop implementation calls sqlite3_close, which flushes pending writes and releases the file handle. You do not need to call conn.close() manually. Rust's ownership model handles the cleanup.

Trust the drop implementation. Manual cleanup is where resource leaks start.

A realistic data flow

Real applications rarely run raw SQL strings. They define structs, map them to tables, and use type-safe queries. Here is how that looks with diesel.

use diesel::prelude::*;
use diesel::sqlite::SqliteConnection;

/// Represents a row in the users table.
#[derive(Queryable, Insertable, Debug)]
#[diesel(table_name = users)]
struct User {
    id: i32,
    name: String,
    email: String,
}

/// Open the database and run a type-safe insert plus select.
fn main() -> Result<(), Box<dyn std::error::Error>> {
    let mut conn = SqliteConnection::establish("app.db")?;

    // Create the table using raw SQL for brevity.
    diesel::sql_query(
        "CREATE TABLE IF NOT EXISTS users (
            id INTEGER PRIMARY KEY AUTOINCREMENT,
            name TEXT NOT NULL,
            email TEXT NOT NULL UNIQUE
        )"
    ).execute(&mut conn)?;

    // Insert a new user using the struct mapping.
    let new_user = User { id: 0, name: "Ada".to_string(), email: "ada@example.com".to_string() };
    diesel::insert_into(diesel::table!(users))
        .values(&new_user)
        .execute(&mut conn)?;

    // Query all users and collect them into a vector.
    let all_users: Vec<User> = diesel::table!(users).load(&mut conn)?;

    // Print each user.
    for user in all_users {
        println!("{} <{}>", user.name, user.email);
    }

    Ok(())
}

The #[derive(Queryable, Insertable)] macros generate the code that maps Rust fields to SQLite columns. Queryable tells diesel how to read a row into a User. Insertable tells it how to turn a User into an INSERT statement. The #[diesel(table_name = users)] attribute links the struct to the table definition.

Convention aside: always derive Debug on your database structs. When a query fails or returns unexpected data, println!("{:?}", users) saves hours of guessing. The community treats Debug as a baseline requirement for any type that crosses the database boundary.

Where things usually trip up

The most common failure is a missing feature flag. If you add diesel = "2.2" without sqlite, the compiler rejects your code with E0432 (use of undeclared type or module). The SqliteConnection type simply does not exist in the compiled crate. Add the feature and rebuild.

The second common failure is a version mismatch between diesel and diesel_migrations. The migration CLI expects the exact same major version as your runtime crate. If Cargo.toml says diesel = "2.2" but you installed diesel_cli version 1.x, the generated migration files will not compile. Keep them aligned.

The third common failure is forgetting that SQLite is single-threaded by default. diesel connections are not Send or Sync. You cannot share a single SqliteConnection across threads. If you need concurrency, open a separate connection per thread, or use a connection pool like r2d2 with diesel. SQLite handles concurrent reads well, but concurrent writes serialize behind an internal lock. Design your workload around that reality.

The compiler will reject shared mutable state with E0277 (trait bound not satisfied) when you try to move a connection into a thread. Do not fight it. Open a new connection or use a pool.

Picking the right SQLite crate

Use diesel with libsqlite3-sys/bundled when you want type-safe query building, automatic struct mapping, and a single distributable binary without system dependencies. Use rusqlite when you prefer direct control over statement preparation, parameter binding, and row iteration, and you want a lighter dependency footprint. Use sqlx when you need compile-time SQL verification, async support, and a unified API across SQLite, PostgreSQL, and MySQL. Use system-linked SQLite when you are building for a constrained embedded target where binary size matters more than reproducibility, and you can guarantee the host environment provides the exact library version you need.

Reach for diesel when your schema is stable and you value developer ergonomics over raw query flexibility. Reach for rusqlite when you are writing a database tool, a migration runner, or a library that needs to expose low-level SQLite features like custom functions or virtual tables. Reach for sqlx when your project already uses async Rust and you want the compiler to catch typos in your SQL before runtime. Reach for system linking only when you have audited the target environment and accept the maintenance burden of version drift.

Where to go next