SQLite Debugging in Tauri: Verify CRUD with CLI & println! | Rust Desktop App Tutorial
Video: SQLite Debugging in Tauri: Verify CRUD with CLI & println! | Rust Desktop App Tutorial by Taught by Celeste AI - AI Coding Coach
When your app says "saved" but the database disagrees, you have a debugging problem. Two tools find the truth: the SQLite CLI and Rust's println.
You ship a CRUD UI. The list says four contacts; opening the database in another tool shows three. Or a save button reports success but the next refresh shows the old value. Database bugs are common because the layers of abstraction (UI → IPC → driver → SQLite) each hide details.
This lesson is a practical debugging tour: where the database file lives, how to inspect it directly, and how to log what your Rust commands are actually executing.
Where is the database file?
Tauri's SQL plugin stores SQLite files under the app's data directory:
- macOS:
~/Library/Application Support/<bundle.identifier>/ - Linux:
~/.local/share/<bundle.identifier>/ - Windows:
%APPDATA%\<bundle.identifier>\
The bundle identifier is set in tauri.conf.json as identifier.
To find the path programmatically from a Rust command:
use tauri::Manager;
#[tauri::command]
fn db_path(app: tauri::AppHandle) -> String {
app.path()
.app_data_dir()
.unwrap()
.join("contacts.db")
.to_string_lossy()
.to_string()
}
Call once during development, print the result, you know where to look.
Inspecting with the SQLite CLI
sqlite3 ~/Library/Application\ Support/com.you.my-app/contacts.db
You're now in the SQLite prompt. Useful commands:
.tables # list all tables
.schema contacts # show CREATE TABLE for `contacts`
SELECT * FROM contacts; # see the actual rows
SELECT count(*) FROM contacts; # count
.mode column # nicer output formatting
.headers on # show column names
.quit # exit
If the CLI shows different data than your app, you have an answer: the bug is between the app and the database, not in the database itself. Either you're querying the wrong DB (a typo in the path) or your transaction isn't committing.
Inspecting with a GUI
For a richer experience, use:
- DB Browser for SQLite (cross-platform, free).
- TablePlus (cross-platform, paid).
- DataGrip (JetBrains, paid).
Open the same .db file the app uses. You can browse, edit, and run queries while your app is running. SQLite supports concurrent reads safely; concurrent writes need to coordinate.
Logging from Rust commands
For commands you wrote yourself, println! is the simplest debug tool:
#[tauri::command]
fn add_contact(state: State<DbInstances>, name: String, email: String) -> Result<(), String> {
println!("=== add_contact ===");
println!(" name: {}", name);
println!(" email: {}", email);
// ... do the insert
println!(" result: ok");
Ok(())
}
Run npm run tauri dev. The Rust println output appears in the terminal where you ran the command. Now you see exactly what the frontend sent and what the command did.
Capturing the actual SQL
When using the SQL plugin's execute/select from JS, you don't see the literal SQL the driver sent. To inspect, pull the operation into a Rust command and print there:
use sqlx::SqlitePool;
use tauri_plugin_sql::DbInstances;
#[tauri::command]
async fn add_contact_debug(
state: State<'_, DbInstances>,
name: String,
email: String,
) -> Result<i64, String> {
let pool: &SqlitePool = state.get_pool("sqlite:contacts.db").await
.ok_or("DB not loaded")?;
let sql = "INSERT INTO contacts (name, email) VALUES (?, ?)";
println!("SQL: {} params: ({}, {})", sql, name, email);
let result = sqlx::query(sql).bind(&name).bind(&email)
.execute(pool).await.map_err(|e| {
println!(" error: {}", e);
e.to_string()
})?;
let id = result.last_insert_rowid();
println!(" inserted id: {}", id);
Ok(id)
}
Now you see the SQL, the parameters, the result, and any error. For a fast feedback loop, log the row count before and after so you can see whether the insert actually happened.
Verifying after a write
A pattern that catches a class of bugs: after writing, read back and assert.
sqlx::query("INSERT INTO contacts (name) VALUES (?)")
.bind("Alice").execute(pool).await?;
let count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM contacts")
.fetch_one(pool).await?;
println!("After insert, count = {}", count);
Mismatch? Your insert isn't persisting. Common cause: a transaction you forgot to commit.
Transaction sanity
let mut tx = pool.begin().await?;
sqlx::query("INSERT INTO ...").execute(&mut *tx).await?;
// forgot tx.commit() ← bug: changes never reach the DB
If you begin a transaction, you must commit (or rollback). Without commit, when the transaction goes out of scope, sqlx auto-rolls back. The UI says "saved" because no error returned, but the row never landed.
For one-shot queries, skip the transaction. For multi-statement atomic operations, always commit.
Frontend-side debugging
In the JS console (Right-click → Inspect Element → Console in dev mode), you can call the database directly:
const db = await Database.load("sqlite:contacts.db");
const rows = await db.select("SELECT * FROM contacts");
console.table(rows);
console.table renders rows nicely in the dev tools. Good for sanity-checking what the JS-side select is actually returning.
Migration debugging
If a migration didn't run, your schema is out of sync. Symptom: queries fail with "no such column."
The plugin tracks applied migrations in a _sqlx_migrations table. To see what's been applied:
SELECT * FROM _sqlx_migrations;
If the migration you expected isn't there, it didn't run. Reasons:
- You forgot to add it to the
migrationsvector. - The version number conflicts with an earlier migration.
- The plugin failed to apply it and crashed silently.
A nuclear option during development: delete the database file. Next launch starts fresh and re-runs all migrations. Don't do this in production — you lose all user data.
Common mistakes
Wrong database path. Two .db files in different directories. Your inspection tool opens one, your app opens the other.
Forgetting to commit transactions. UI succeeds, DB stays empty. Always commit explicitly.
Mixing types. bind(true) works in Rust but the column has INTEGER storage. Read back as i64 (0 or 1), not bool.
Not handling errors. If execute returns an error, your Rust command should propagate it. Otherwise the frontend thinks success.
Running migrations while the DB is open elsewhere. Especially with GUI tools — they may hold a write lock. Close the tool before testing migrations.
What's next
Next lesson: console debugging in Tauri — DevTools, terminal logging, the broader debugging surface beyond just SQL.
Recap
The DB lives in the OS app-data directory. Inspect with the sqlite3 CLI or a GUI tool. Log SQL and parameters from Rust commands with println!. Commit transactions. Verify writes by reading back. Check _sqlx_migrations to see which migrations applied.
Next: console debugging.