Using tauri-plugin-sql v2? Use sqlx for Rust Command-Side Queries
tauri-plugin-sql is useful when the frontend needs to talk to a database from JavaScript. It gives the webview a database API and supports common SQL backends through Rust.
But a different question comes up once your Tauri app has Rust commands: should those Rust commands call through tauri-plugin-sql, or should they use a Rust database crate directly?
The practical answer is: use tauri-plugin-sql for frontend-side database access, and use sqlx directly inside Rust command handlers.
The decision point
Use this split:
React / TypeScript needs simple DB access
-> tauri-plugin-sql JavaScript API
Rust command needs DB access
-> sqlx in Rust
Both sides need access
-> share schema and migrations, but keep each side's database calls native
The plugin is not a general "Rust command database layer." It is primarily a bridge that exposes database operations to the frontend. When you are already in Rust, a direct Rust database library is clearer, easier to test, and avoids unnecessary IPC-like indirection.
Use sqlx inside Rust commands
A Rust command can receive app state, open or reuse a pool, and query directly:
use tauri::State;
use sqlx::{Row, SqlitePool};
pub struct AppState {
pub db: SqlitePool,
}
#[derive(serde::Serialize)]
#[serde(rename_all = "camelCase")]
pub struct Campaign {
pub id: i64,
pub name: String,
pub point_value: i64,
}
#[tauri::command]
pub async fn list_campaigns(
state: State<'_, AppState>,
) -> Result<Vec<Campaign>, String> {
let rows = sqlx::query(
"SELECT id, name, point_value FROM campaigns ORDER BY id DESC"
)
.fetch_all(&state.db)
.await
.map_err(|error| error.to_string())?;
Ok(rows
.into_iter()
.map(|row| Campaign {
id: row.get("id"),
name: row.get("name"),
point_value: row.get("point_value"),
})
.collect())
}
The command returns a typed Rust struct. The frontend receives a normal Tauri command response.
Initialize the database once
Avoid reconnecting on every command if the app performs regular database work. Create a pool at startup and put it in managed state:
pub async fn create_db_pool(app_data_dir: &std::path::Path) -> Result<SqlitePool, sqlx::Error> {
let db_path = app_data_dir.join("app.sqlite");
let url = format!("sqlite:{}?mode=rwc", db_path.display());
SqlitePool::connect(&url).await
}
Then manage it in Tauri:
let db = create_db_pool(&app_data_dir).await?;
tauri::Builder::default()
.manage(AppState { db })
.invoke_handler(tauri::generate_handler![list_campaigns])
.run(tauri::generate_context!())?;
This keeps connection ownership explicit and avoids hiding database lifecycle inside UI components.
Keep migrations on the Rust side when Rust owns the schema
If Rust commands enforce business rules or aggregate data, Rust should also own schema initialization:
pub async fn run_migrations(pool: &SqlitePool) -> Result<(), sqlx::Error> {
sqlx::migrate!("./migrations").run(pool).await
}
Run migrations at startup before commands that depend on the schema become available. If the frontend also uses tauri-plugin-sql, both sides still point at the same database file, but schema creation remains centralized.
When the JavaScript plugin API is still useful
The JavaScript API is a good fit when:
- The UI needs simple local preferences or lightweight CRUD.
- The operation is mostly presentation-facing.
- The query does not require privileged filesystem access or Rust-only parsing.
- You want fewer Rust commands for low-risk data screens.
Rust-side sqlx is a better fit when:
- The query is part of a Tauri command.
- The operation uses Rust-only libraries.
- The operation needs filesystem access, parsing, aggregation, or batch performance.
- You need Rust tests around database behavior.
- You want command responses to be typed Rust structs.
Do not split business rules across both sides
The risky architecture is not "plugin plus sqlx." The risky architecture is duplicating the same business rules in both places.
For example, avoid validating a campaign status in JavaScript for one screen and in Rust for another. Put the rule in one layer. If Rust commands are the source of truth, keep validation in Rust and let the frontend display the result. If JavaScript owns a lightweight preference table, keep it simple and do not mix it with Rust-owned domain state.
Verification checklist
Before shipping a Tauri database feature:
- The database file path is created from an app data directory, not a random working directory.
- Migrations run before query commands.
- Rust commands use
sqlxdirectly when they need database access. - Frontend plugin queries do not duplicate Rust business rules.
- Returned structs use explicit serde naming for TypeScript.
- Tests cover at least one migration and one command query.
References
Summary
tauri-plugin-sql and sqlx solve adjacent problems. Use the plugin when JavaScript needs database access. Use sqlx when Rust commands need database access. Keeping that boundary clear makes the app easier to test, easier to reason about, and less likely to grow a hidden second database layer.