SQL Plugin

Store and query relational data in Tauri v2 apps using SQLite, MySQL, or PostgreSQL from your React frontend.

The SQL plugin bridges your React frontend and a real relational database running inside your Tauri desktop application. You get full Create, Read, Update, Delete (CRUD) operations, parameterized queries, and schema migrations—without leaving the JavaScript side. The plugin wraps the battle‑tested sqlx Rust library and works with SQLite (embedded, no server needed), MySQL, and PostgreSQL.

Introduction

Adding a proper database to a desktop app used to mean spinning up a separate server process or dealing with fragile file‑based workarounds. Tauri’s SQL plugin removes that friction. The dedicated SQL Plugin introduction covers engines, setup, and permissions in isolation. It embeds database drivers directly into your app’s Rust backend, then exposes a clean, Promise‑based API to your React code. You get relational data storage, transactions, and complex queries as a first‑class feature.

What the SQL Plugin Provides

  • Multiple database engines – SQLite, MySQL, and PostgreSQL, selected with a single Cargo feature flag.
  • JavaScript bindings – All operations happen through the @tauri-apps/plugin-sql npm package, with full TypeScript support.
  • Connection pooling – The Rust backend manages a connection pool automatically, so you don’t have to worry about threading or resource exhaustion.
  • Schema migrations – Define versioned SQL migrations in Rust that run automatically when the database is first loaded.
  • Fine‑grained permissions – Control exactly which operations the frontend can perform through Tauri’s capability system.

Why Use a SQL Database Instead of Local Storage?

The browser‑style localStorage or IndexedDB works for tiny amounts of configuration. Once your app needs to filter thousands of records, join related tables, or guarantee data integrity, those tools break down. A SQL database gives you more than the Store Plugin can:

  • Complex queries (aggregations, joins, subqueries)
  • Atomic transactions that keep your data consistent
  • Full‑text search and indexing
  • Schema enforcement so bad data never enters the store

A desktop to‑do list, a note‑taking app, a small inventory manager—these are all applications where the SQL plugin shines.

How It Works Under the Hood

When you call Database.load("sqlite:mydata.db") from React, the plugin’s JavaScript layer sends a command to the Rust backend. The backend opens (or creates) the SQLite file, sets up a connection pool, and returns a handle that the frontend uses for subsequent queries. Each execute or select call translates into a Tauri IPC (Inter‑Process Communication) message that runs the SQL on the Rust side and sends the result back as JSON. All database logic stays in the trusted Rust process, and your React code never touches raw database files directly.

Rust backend required:

The Rust part of the plugin is mandatory. Even if you never write Rust code yourself, the plugin’s core runs there. The npm package is a thin wrapper that calls into the already‑installed Rust plugin.

Supported Database Engines

EngineBest forConnection URL pattern
SQLiteLocal, single‑user apps; no server to installsqlite:filename.db
MySQLExisting MySQL servers, multi‑user scenariosmysql://user:pass@host/database
PostgreSQLAdvanced features, geographic data, JSON columnspostgres://user:pass@host/database

SQLite is the most common choice for standalone Tauri apps because it’s embedded and requires zero configuration. MySQL and PostgreSQL are useful when your desktop app talks to an existing server that other applications or services already use.

Installation

You can install the plugin with a single command that handles both the Rust dependency and the JavaScript bindings, or you can do it manually if you need precise control. That is the same plugin installation flow used throughout this chapter.

Run the Tauri CLI’s add command inside your project root. It modifies Cargo.toml, lib.rs, and installs the npm package in one step:

npm run tauri add sql

After the command finishes, select your database engine by adding the corresponding Cargo feature:

cargo add tauri-plugin-sql --features sqlite

Everything in place:

The automatic setup wires the plugin into your lib.rs file. If you open src-tauri/src/lib.rs, you should see a .plugin(tauri_plugin_sql::Builder::default().build()) line. That means the Rust side is ready.

Manual Installation

If you prefer to understand each piece or are troubleshooting an existing project, follow the steps below.

1

Step 1: Add the Rust dependency

Open src-tauri/Cargo.toml and add the plugin with the feature flag for your chosen database engine. For SQLite:

[dependencies]
tauri-plugin-sql = { version = "2", features = ["sqlite"] }

Replace "sqlite" with "mysql" or "postgres" if needed.

2

Step 2: Register the plugin in lib.rs

Ensure the plugin is initialized when Tauri starts. Locate the run function inside src-tauri/src/lib.rs and add the .plugin() call:

#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
    tauri::Builder::default()
        .plugin(tauri_plugin_sql::Builder::default().build())
        .run(tauri::generate_context!())
        .expect("error while running tauri application");
}

If your template uses main.rs that calls lib::run(), keep the plugin registration in lib.rs—this is the standard Tauri v2 pattern.

3

Step 3: Install the JavaScript bindings

Use your package manager to add the frontend package:

npm install @tauri-apps/plugin-sql

This gives you the Database class and TypeScript types for all queries.

4

Step 4: Grant the required permissions

By default, all plugin commands are blocked. Create or edit src-tauri/capabilities/default.json to allow the operations your app needs:

{
  "identifier": "default",
  "description": "Default capabilities for the main window",
  "windows": ["main"],
  "permissions": [
    "core:default",
    "sql:default",
    "sql:allow-execute",
    "sql:allow-select"
  ]
}

The "sql:default" permission allows loading, closing, and reading data. "sql:allow-execute" explicitly grants write operations like INSERT, UPDATE, and DELETE.

Missing permissions break your app:

If you call db.execute() without sql:allow-execute, the Tauri runtime will silently block the command. You’ll see an error in the console, not a helpful popup. Always test with the permissions you intend to ship.

Database Connection

Before any query can run, you need a connected Database instance. The plugin offers two static factory methods: load (async, connects immediately) and get (sync, defers connection). Database Connection is the focused walkthrough.

Connection Strings

The connection string tells the plugin which engine to use and where the data lives. The format changes per engine.

A SQLite connection string starts with sqlite: followed by a filename. The path is relative to tauri::api::path::BaseDirectory::AppConfig, which on desktop platforms usually points to the app’s configuration directory (e.g., ~/.config/your-app/ on Linux). The file is created if it doesn’t exist.

import Database from '@tauri-apps/plugin-sql';
const db = await Database.load('sqlite:myapp.db');

Using Database.load()

load establishes the connection pool right away and returns a Promise<Database>. Use it when you need the database ready before any user interaction, for example during app startup. It also triggers any migrations registered for that connection string (more on migrations later).

// At the top of your React component or in a module-level init
async function initDatabase(): Promise<Database> {
  const db = await Database.load('sqlite:myapp.db');
  return db;
}

Using Database.get() — Lazy Loading

get returns a Database instance synchronously but does not open the actual connection until you run the first query. This avoids blocking your app’s startup on I/O. It’s useful when a database is only needed on certain screens.

const db = Database.get('sqlite:myapp.db');
// No connection yet. The first execute() or select() will open it.

The difference is subtle but matters: load can fail early with a clear error if the database is unreachable; get defers that failure to the first query, which might confuse a user who clicks a button and gets an error. Prefer load for mandatory databases.

Closing the Connection

When your app no longer needs a database, call close() to release resources. This is important for MySQL and PostgreSQL connections, which hold server‑side slots. For SQLite, closing flushes any pending writes and unlocks the file.

await db.close();

If you manage multiple databases with different connection strings, pass the connection string to close() to target a specific pool. Otherwise, close() shuts down all pools the plugin knows about.

Example: Connecting in a React Component

Below is a minimal component that opens a SQLite database when the component mounts and stores the Database instance in a ref.

import { useEffect, useRef, useState } from 'react';
import Database from '@tauri-apps/plugin-sql';
export default function App() {
  const dbRef = useRef<Database | null>(null);
  const [ready, setReady] = useState(false);
  useEffect(() => {
    async function connect() {
      const db = await Database.load('sqlite:myapp.db');
      dbRef.current = db;
      setReady(true);
    }
    connect();
  }, []);
  if (!ready) return <p>Connecting to database…</p>;
  return <p>Database ready.</p>;
}

Connection pooling means you rarely need to disconnect:

The Rust backend keeps a connection pool alive. Repeatedly calling load with the same connection string returns a handle to the existing pool; it does not open duplicate pools. You only need to call close() when your app is shutting down or when you are certain the database will never be used again.

Executing Queries

The Database object exposes two main methods: execute for statements that modify data, and select for reading. Both accept parameterized values to prevent SQL injection. See Executing Queries for INSERT, UPDATE, DELETE, and SELECT examples.

The execute Method

execute runs INSERT, UPDATE, DELETE, and other statements that change the database. It returns a QueryResult containing the number of rows affected and the last inserted ID (except on PostgreSQL, where lastInsertId is undefined—see the QueryResult table below).

Parameter syntax varies by database engine.

Use $1, $2, … as placeholders. The values are passed as an array in the order they appear.

const result = await db.execute(
  'INSERT INTO tasks (title, completed) VALUES ($1, $2)',
  ['Buy groceries', false]
);
console.log(`Inserted row with id: ${result.lastInsertId}`);

Mixing placeholder styles breaks queries:

SQLite and PostgreSQL use $1, MySQL uses ?. If you accidentally use ? with SQLite or $1 with MySQL, the query will fail at runtime. Choose the style that matches your engine and stick to it.

The select Method

select executes a SELECT query and returns the rows as an array of objects. It is generic, so you can type the returned rows to match your table schema.

interface Task {
  id: number;
  title: string;
  completed: boolean;
}
const rows = await db.select<Task[]>(
  'SELECT * FROM tasks WHERE completed = $1',
  [true]
);
for (const task of rows) {
  console.log(task.title);
}

The returned object properties match the column names in your SELECT clause. TypeScript can’t verify this at compile time, so define interfaces that mirror your actual schema to avoid runtime surprises.

Understanding QueryResult

The execute method returns a QueryResult with the following properties:

PropertyTypeDescription
lastInsertIdnumber?The auto‑generated primary key of the last inserted row. Not set for PostgreSQL.
rowsAffectednumberThe count of rows that were inserted, updated, or deleted.

For PostgreSQL, if you need the ID of a newly inserted row, use a RETURNING clause with select:

const [row] = await db.select<[{ id: number }]>(
  'INSERT INTO tasks (title, completed) VALUES ($1, $2) RETURNING id',
  ['Buy groceries', false]
);
console.log(row.id);

Parameter Binding — Why It Matters

Never concatenate user input into a SQL string. The parameterized style ($1, ?) ensures that user values are treated as data, not as executable SQL. The Rust‑side driver handles escaping and quoting, protecting you from injection attacks. This holds even if your app only uses local SQLite files—a seemingly harmless input like '; DROP TABLE tasks; -- would otherwise delete your data.

Building a Small Task Manager

Let’s put everything together. This React component connects to a SQLite database, creates a tasks table if it doesn’t exist, and lets the user add and list tasks. We’ll keep the table creation simple here; for production, use migrations (covered in Best Practices).

import { useEffect, useRef, useState } from 'react';
import Database from '@tauri-apps/plugin-sql';
interface Task {
  id: number;
  title: string;
  completed: boolean;
}
export default function TaskManager() {
  const dbRef = useRef<Database | null>(null);
  const [tasks, setTasks] = useState<Task[]>([]);
  const [newTitle, setNewTitle] = useState('');
  const [ready, setReady] = useState(false);
  useEffect(() => {
    async function setup() {
      const db = await Database.load('sqlite:myapp.db');
      // Ensure the table exists (migrations are a better fit for real apps)
      await db.execute(
        `CREATE TABLE IF NOT EXISTS tasks (
          id INTEGER PRIMARY KEY AUTOINCREMENT,
          title TEXT NOT NULL,
          completed BOOLEAN NOT NULL DEFAULT 0
        )`
      );
      dbRef.current = db;
      setReady(true);
      await refreshTasks(db);
    }
    setup();
  }, []);
  async function refreshTasks(db: Database) {
    const rows = await db.select<Task[]>('SELECT * FROM tasks ORDER BY id DESC');
    setTasks(rows);
  }
  async function addTask() {
    if (!newTitle.trim() || !dbRef.current) return;
    await dbRef.current.execute(
      'INSERT INTO tasks (title, completed) VALUES ($1, $2)',
      [newTitle.trim(), false]
    );
    setNewTitle('');
    await refreshTasks(dbRef.current);
  }
  if (!ready) return <p>Loading database…</p>;
  return (
    <div>
      <h1>Task Manager</h1>
      <input
        type="text"
        value={newTitle}
        onChange={(e) => setNewTitle(e.target.value)}
        placeholder="New task title"
      />
      <button onClick={addTask}>Add</button>
      <ul>
        {tasks.map((task) => (
          <li key={task.id}>{task.title}</li>
        ))}
      </ul>
    </div>
  );
}

This example demonstrates connection, table creation, insertion, and selection—all the core patterns you need. The CREATE TABLE IF NOT EXISTS is safe to run multiple times, but for real projects with evolving schemas, move that logic into a migration so it’s tracked and versioned.

Schema management at scale:

The CREATE TABLE IF NOT EXISTS trick works for prototypes. Once you add columns, rename tables, or index data, you need a migration system that tracks what has already run. The SQL plugin provides first‑class migration support, discussed under Best Practices.

Best Practices

A working database is one thing; a maintainable, secure, and performant database is another. These practices help you avoid the most common stumbling blocks. The SQL Plugin best practices page collects parameterized queries, transactions, and migrations in one place.

Permission Configuration

Tauri’s capability system is the gatekeeper for all plugin commands. The sql:default permission allows load, close, and select operations. Write operations (execute) require sql:allow-execute. Grant only what each window needs.

{
  "identifier": "default",
  "description": "Capabilities for the main window",
  "windows": ["main"],
  "permissions": [
    "core:default",
    "sql:default",
    "sql:allow-execute"
  ]
}

Never ship with carte blanche permissions:

If your app has a secondary window that only displays data, do not give it sql:allow-execute. Least privilege reduces the damage from a hypothetical XSS vulnerability in your frontend.

Schema Migrations

Schema changes—adding a column, splitting a table, creating an index—should never happen by running random ALTER statements from the frontend. The plugin includes a migration system that lets you define versioned SQL files and run them automatically.

Each migration has a version number, a description, the SQL to run, and a kind (Up for forward migration, Down for rolling back). Define them in Rust when you register the plugin:

use tauri_plugin_sql::{Builder, Migration, MigrationKind};
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
    let migrations = vec![
        Migration {
            version: 1,
            description: "create tasks table",
            sql: "CREATE TABLE tasks (id INTEGER PRIMARY KEY AUTOINCREMENT, title TEXT NOT NULL, completed BOOLEAN NOT NULL DEFAULT 0);",
            kind: MigrationKind::Up,
        },
        Migration {
            version: 2,
            description: "add priority column to tasks",
            sql: "ALTER TABLE tasks ADD COLUMN priority INTEGER DEFAULT 0;",
            kind: MigrationKind::Up,
        },
    ];
    tauri::Builder::default()
        .plugin(
            Builder::default()
                .add_migrations("sqlite:myapp.db", migrations)
                .build(),
        )
        .run(tauri::generate_context!())
        .expect("error while running tauri application");
}

When the connection string is loaded (either via Database.load() or by listing it in tauri.conf.json’s plugins.sql.preload array), the plugin checks which migrations have already been applied and runs any new ones in order.

{
  "plugins": {
    "sql": {
      "preload": ["sqlite:myapp.db"]
    }
  }
}

Migrations run inside a transaction, so if one fails, the database state rolls back cleanly. This prevents half‑applied schemas.

Migrations must be idempotent:

Each migration should be written so that running it twice produces the same result. The plugin tracks applied versions, but defensive IF NOT EXISTS clauses and careful ordering prevent surprises during development when you sometimes reset the database.

Connection Lifecycle

  • Load once, reuse the instance. The Database object is a thin handle to the pool. Store it in a React ref or a module-level variable rather than opening a new connection on every render.
  • Close only when the app is shutting down. For SQLite, an unclosed database is not a critical problem, but for MySQL and PostgreSQL, lingering idle connections waste server resources.
  • Handle load failures gracefully. If the database file is on a network drive that disappears, load will throw. Show a meaningful error screen instead of crashing.

Security Considerations

  • Always use parameterized queries. Even in desktop apps, user input can be malicious. The $1 / ? syntax is your defense.
  • Never expose the database file path to the frontend without sanitization. The sqlite: prefix maps to a trusted app directory; do not allow users to specify arbitrary paths like ../../system.db.
  • Review the permission table. If you add a feature that executes raw SQL from user‑supplied text, consider whether sql:allow-execute should be restricted further (Tauri allows scoping commands to specific windows).

Testing Migrations and Queries

Write small integration tests that run your migrations against a temporary SQLite file. Because the plugin uses sqlx under the hood, you can test the same SQL in isolation with the sqlx crate’s test utilities before wiring them into Tauri. This catches schema drift early. For frontend tests, mock the Database class or spin up a test Tauri context so you can verify that your React logic handles empty result sets and error conditions properly.

Summary

The SQL plugin brings relational data to your Tauri v2 desktop application without requiring a server. SQLite is the simplest path for local‑first apps; MySQL and PostgreSQL connect your desktop tool to existing infrastructure. The plugin handles connection pooling, parameter binding, and schema migrations so you can focus on what your data does rather than how it’s stored.

The most important takeaways:

  • Pick SQLite unless you have a specific reason to reach for a server‑based engine.
  • Register migrations in Rust; don’t evolve your schema from the frontend.
  • Grant permissions with the principle of least privilege—sql:allow-execute only where writes are necessary.
  • Use load for databases you need at startup, get for optional ones.

Introduction to the SQL Plugin

Learn what the Tauri SQL plugin is, which databases it supports, and how to set it up in a Tauri v2 project with React and Vite

Database Connection in the Tauri SQL Plugin

Step-by-step guide to connecting your Tauri v2 app to SQLite, MySQL, or PostgreSQL databases using the official SQL plugin.

Executing Queries

How to run SQL statements from the frontend using the Tauri SQL plugin including SELECT INSERT UPDATE and DELETE operations

Best Practices for the SQL Plugin

Production-ready patterns for using Tauri SQL plugin safely - parameterized queries, transactions, error handling, connection management, migrations, permissions, and common pitfalls to avoid