--- name: raw-tables description: PowerSync Raw Tables — native SQLite tables bypassing JSON views, with multi-SDK examples (JS, Dart, Kotlin, Swift, Rust), triggers, local-only columns, and migration strategies metadata: tags: raw-tables, sqlite, advanced, powersync, javascript, dart, kotlin, swift, rust --- # Raw Tables > **Load this when** the project needs native SQLite tables (column types, constraints, indexes, generated columns) instead of PowerSync's default JSON-based views. Works across all SDKs except .NET. Raw tables let PowerSync sync data directly into native SQLite tables you define, instead of storing data as JSON in `ps_data__` and exposing it via views. This gives full SQLite control and better query performance. See [Raw Tables](https://docs.powersync.com/client-sdks/advanced/raw-tables.md) for the full reference. ## Table of Contents - [When to Use](#when-to-use-raw-tables) - [SDK Availability](#sdk-availability) - [Defining Raw Tables](#defining-raw-tables) (Inferred vs Explicit) - [Triggers for Local Writes](#triggers-for-local-writes) (Inferred vs Explicit) - [Local-Only Columns](#local-only-columns) - [Migrations](#migrations) - [Caveats](#caveats) **Status:** Experimental — not covered by semver stability guarantees. ## SDK Availability | SDK | Min Version | Package | |-----|-------------|---------| | JavaScript (Web) | 1.35.0 | `@powersync/web` | | JavaScript (React Native) | 1.31.0 | `@powersync/react-native` | | JavaScript (Node) | 0.18.0 | `@powersync/node` | | Dart / Flutter | 1.18.0 | `package:powersync` | | Kotlin | 1.11.0 | `com.powersync:core` | | Swift | 1.12.0 | `PowerSync` | | Rust | 0.0.4 | `powersync` | | .NET | — | **Not yet available** | ## When to Use Raw Tables - Indexes on expressions or `GENERATED` columns (PowerSync's default schema only supports basic column indexes) - Improved query performance for aggregations (`SUM`, `GROUP BY`) — reads typed columns directly instead of extracting from JSON - Reduced storage overhead — no JSON object per row - SQLite constraints (`FOREIGN KEY`, `NOT NULL`, `CHECK`) - Local-only columns that persist across syncs but never upload ## Defining Raw Tables You must create the actual SQLite table yourself before calling `connect()`: ```sql CREATE TABLE IF NOT EXISTS todo_lists ( id TEXT NOT NULL PRIMARY KEY, created_by TEXT NOT NULL, title TEXT NOT NULL, content TEXT ) STRICT; ``` ### Inferred Setup (Recommended) When the local table structure matches the synced table, the SDK can infer `put`/`delete` statements automatically: **JavaScript:** ```ts const mySchema = new Schema({}); mySchema.withRawTables({ todo_lists: { schema: {} } }); ``` **Dart:** ```dart const schema = Schema([], rawTables: [ RawTable.inferred(name: 'todo_lists', schema: RawTableSchema()), ]); ``` **Kotlin:** ```kotlin val schema = Schema(listOf( RawTable(name = "todo_lists", schema = RawTableSchema()) )) ``` **Swift:** ```swift let lists = RawTable(name: "todo_lists", schema: RawTableSchema()) let schema = Schema(lists) ``` **Rust:** ```rust let table = RawTable::with_schema("todo_lists", RawTableSchema::default()); schema.raw_tables.push(table); ``` Use inferred setup when the local table directly maps to the synced output table. Use explicit setup (below) for transformations, custom defaults, the `_extra` column pattern, or when local and backend table names differ. ### Explicit Setup Provide `put` and `delete` SQL statements with positional parameters: **JavaScript:** ```ts mySchema.withRawTables({ todo_lists: { put: { sql: 'INSERT OR REPLACE INTO todo_lists (id, created_by, title, content) VALUES (?, ?, ?, ?)', params: ['Id', { Column: 'created_by' }, { Column: 'title' }, { Column: 'content' }] }, delete: { sql: 'DELETE FROM todo_lists WHERE id = ?', params: ['Id'] } } }); ``` **Dart:** ```dart RawTable( name: 'todo_lists', put: PendingStatement( sql: 'INSERT OR REPLACE INTO todo_lists (id, created_by, title, content) VALUES (?, ?, ?, ?)', params: [.id(), .column('created_by'), .column('title'), .column('content')], ), delete: PendingStatement(sql: 'DELETE FROM todo_lists WHERE id = ?', params: [.id()]), ) ``` **Kotlin:** ```kotlin RawTable( name = "todo_lists", put = PendingStatement( "INSERT OR REPLACE INTO todo_lists (id, created_by, title, content) VALUES (?, ?, ?, ?)", listOf(PendingStatementParameter.Id, PendingStatementParameter.Column("created_by"), PendingStatementParameter.Column("title"), PendingStatementParameter.Column("content")) ), delete = PendingStatement("DELETE FROM todo_lists WHERE id = ?", listOf(PendingStatementParameter.Id)) ) ``` **Swift:** ```swift RawTable( name: "todo_lists", put: PendingStatement( sql: "INSERT OR REPLACE INTO todo_lists (id, created_by, title, content) VALUES (?, ?, ?, ?)", parameters: [.id, .column("created_by"), .column("title"), .column("content")] ), delete: PendingStatement(sql: "DELETE FROM todo_lists WHERE id = ?", parameters: [.id]) ) ``` Parameter types: `Id` = row ID from sync service, `Column("name")` = column value from synced row, `Rest` = remaining columns as JSON (for the `_extra` pattern). ## Triggers for Local Writes Raw tables need triggers to capture local writes into PowerSync's upload queue (`powersync_crud` virtual table). ### Inferred Triggers (Recommended) Use `powersync_create_raw_table_crud_trigger` — must be called **after** the `CREATE TABLE`: **JavaScript:** ```ts for (const write of ["INSERT", "UPDATE", "DELETE"]) { await db.execute("SELECT powersync_create_raw_table_crud_trigger(?, ?, ?)", [JSON.stringify(Schema.rawTableToJson(table)), `todo_lists_${write}`, write]); } ``` **Dart:** ```dart for (final write in ["INSERT", "UPDATE", "DELETE"]) { await db.execute("SELECT powersync_create_raw_table_crud_trigger(?, ?, ?)", [json.encode(table), "todo_lists_$write", write]); } ``` **Kotlin:** ```kotlin for (write in listOf("INSERT", "UPDATE", "DELETE")) { database.execute("SELECT powersync_create_raw_table_crud_trigger(?, ?, ?)", listOf(table.jsonDescription(), "todo_lists_$write", write)) } ``` **Swift:** ```swift for write in ["INSERT", "UPDATE", "DELETE"] { try await database.execute( sql: "SELECT powersync_create_raw_table_crud_trigger(?, ?, ?)", parameters: [lists.jsonDescription(), "todo_lists_\(write)", write]) } ``` ### Explicit Triggers Define triggers manually for full control: ```sql CREATE TRIGGER todo_lists_insert AFTER INSERT ON todo_lists FOR EACH ROW BEGIN INSERT INTO powersync_crud (op, id, type, data) VALUES ('PUT', NEW.id, 'todo_lists', json_object( 'created_by', NEW.created_by, 'title', NEW.title, 'content', NEW.content)); END; CREATE TRIGGER todo_lists_update AFTER UPDATE ON todo_lists FOR EACH ROW BEGIN SELECT CASE WHEN (OLD.id != NEW.id) THEN RAISE(FAIL, 'Cannot update id') END; INSERT INTO powersync_crud (op, id, type, data) VALUES ('PATCH', NEW.id, 'todo_lists', json_object( 'created_by', NEW.created_by, 'title', NEW.title, 'content', NEW.content)); END; CREATE TRIGGER todo_lists_delete AFTER DELETE ON todo_lists FOR EACH ROW BEGIN INSERT INTO powersync_crud (op, id, type) VALUES ('DELETE', OLD.id, 'todo_lists'); END; ``` The `powersync_crud` virtual table columns: `op` (PUT/PATCH/DELETE), `id`, `type` (table name), `data` (JSON), `old_values` (optional), `metadata` (optional). ## Local-Only Columns Raw tables can include columns that exist only on the client — never synced or uploaded. Useful for client preferences, UI state, or local notes. Add the column to the table and specify `syncedColumns` in the inferred setup so the SDK knows which columns come from the server: **JavaScript:** ```ts { name: 'todo_lists', schema: { syncedColumns: ['created_by', 'title', 'content'] } } ``` **Dart:** ```dart RawTableSchema(syncedColumns: ['created_by', 'title', 'content']) ``` **Kotlin:** ```kotlin RawTableSchema(syncedColumns = listOf("created_by", "title", "content")) ``` With explicit setup, use `INSERT ... ON CONFLICT(id) DO UPDATE SET` (not `INSERT OR REPLACE`) to avoid resetting local-only columns on sync. Exclude local-only columns from triggers. ## Migrations PowerSync's JSON-based tables need no migrations. Raw tables do — you manage the schema. ### Adding a new raw table If data was already synced before the raw table existed, it's in `ps_untyped`. Copy it after creating the table: ```sql INSERT INTO my_table (id, col1, col2) SELECT id, data ->> 'col1', data ->> 'col2' FROM ps_untyped WHERE type = 'my_table'; DELETE FROM ps_untyped WHERE type = 'my_table'; ``` Not needed if the raw table was present from the first `connect()` call. ### Adding columns Three strategies: 1. **Delete and resync:** `disconnectAndClear(soft: true)` → migrate → reconnect. Safest but requires network. 2. **Trigger resync:** `ALTER TABLE ... ADD COLUMN` with a default → `SELECT powersync_trigger_resync(TRUE)`. App stays usable offline with optimistic defaults until resync completes. 3. **`_extra` column pattern:** Store unknown columns as JSON in an `_extra TEXT` column using the `Rest` parameter. Migrate by extracting from `_extra`: `json_extract(_extra, '$.newCol')`. ## Caveats - **Not available on .NET** yet - **No automatic column migration** — adding columns requires one of the migration strategies above - **Foreign keys** — must use `DEFERRABLE INITIALLY DEFERRED`; enable with `PRAGMA foreign_keys = ON`; avoid FK references from high-priority to lower-priority raw tables - **`disconnectAndClear()` won't clear raw tables** by default — add a `clear` statement to `RawTable` if needed - **Table name** — the `name` property matches the backend table name, not necessarily the local SQLite table name - **Drop and re-create triggers** after altering a raw table