Back to Blog

SQLite Debugging in Tauri: Verify CRUD with CLI & println! | Rust Desktop App Tutorial

Sandy LaneSandy Lane

Video: SQLite Debugging in Tauri: Verify CRUD with CLI & println! | Rust Desktop App Tutorial by Taught by Celeste AI - AI Coding Coach

Take the quiz on the full lesson page
Test what you've read · interactive walkthrough

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 migrations vector.
  • 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.

Ready? Take the quiz on the full lesson page →
Test what you've learned. Watch the lesson and try the interactive quiz on the same page.