AvailabilityFeatures describes on this page were introduced in the following versions of our client SDKs:
- JavaScript (Node:
0.18.0, React-Native:1.31.0, Web:1.35.0) - Dart: Version 1.18.0 of
package:powersync. - Kotlin: Version 1.11.0.
- Swift: Version 1.12.0.
- Rust: Version 0.0.4.
- This feature is not yet available on our .NET SDK.
When to Use Raw Tables
Consider raw tables when you need:- Indexes - PowerSync’s default schema has basic support for indexes on columns, while raw tables give you complete control to create indexes on expressions, use
GENERATEDcolumns, etc. - Improved performance for complex queries (e.g.,
SELECT SUM(value) FROM transactions) - raw tables more efficiently get these values directly from the SQLite column, instead of extracting the value from the JSON object on every row. - Reduced storage overhead - eliminate JSON object overhead for each row in
ps_data__<table>.datacolumn. - To manually create tables - Sometimes you need full control over table creation, for example when implementing custom triggers.
How Raw Tables Work
Current JSON-Based System
Currently the sync system involves two general steps:- Download bucket operations from the PowerSync Service.
- Once the client has a complete checkpoint and no pending local changes in the upload queue, sync the local database with the bucket operations.
ps_data__<table_name> structure containing only an id (TEXT) and data (JSON) column.
PowerSync automatically creates views on that table that extract JSON fields to resemble standard tables reflecting your schema.
Raw Tables Approach
When opting in to raw tables, you are responsible for creating the tables before using them - PowerSync will no longer create them automatically. Because PowerSync takes no control over raw tables, you need to manually:- Define how PowerSync’s schemaless protocol maps to your raw tables — see Define sync mapping for raw tables
- Define triggers that capture local writes from raw tables — see Capture local writes with triggers
Define sync mapping for raw tables
To sync into the rawtodo_lists table instead of ps_data__, PowerSync needs the SQL statements extracting columns from the untyped JSON protocol used during syncing.
Internally, this involves two SQL statements:
- A
putSQL statement for upserts, responsible for creating atodo_listrow or updating it based on itsidand data columns. - A
deleteSQL statement responsible for deletions.
Inferring sync statements
In most cases, theput and delete statements are obvious when looking at the structure of the table.
With the todo_list example, a delete statement would DELETE FROM todo_lists WHERE id = $row_id_to_delete.
Similarly, a put statement would use a straightforward upsert to create or update rows.
When the SDK knows the name of the local table you’re inserting into, it can infer statements automatically
by analyzing the CREATE TABLE structure.
The name of raw tables can be provided with the RawTableSchema type:
local_users table in your SQLite database and want to sync rows
from the users table in your backend.
Here, the name of the raw table must be users to match PowerSync definitions, but the RawTableSchema
type on every SDK has an optional tableName field that can be set to local_users in this case.
Explicit sync statements
To pass statements explicitly, use theput and delete parameters available in each SDK.
A statement consists of two parts:
- An SQL string of the statement to run. It should use positional parameters (
?) as placeholders for values from the synced row. - An array describing the instantiation of positional parameter.
deletestatements can reference the id of the affected row, whileputstatements can also reference individual column values. Arestparameter is also available, see migrations for details on how that can be useful.
CREATE TABLE statement before connect()-ing the database.
Capture local writes with triggers
PowerSync uses an internal SQLite table to collect local writes. For PowerSync-managed views, a trigger for insertions, updates and deletions automatically forwards local mutations into this table. When using raw tables, defining those triggers is your responsibility. The PowerSync SQLite extension creates an insert-only virtual table namedpowersync_crud with these columns:
powersync_crud to record writes on raw tables.
Like with statements, these triggers can usually be inferred from the schema of the table.
Inferred triggers
Thepowersync_create_raw_table_crud_trigger SQL function is available in migrations to create triggers for
raw tables. It takes three arguments:
- A JSON description of the raw table with options, which can be generated by PowerSync SDKs.
- The name of the trigger to create.
- The type of write for which to generate a trigger (
INSERT,UPDATEorDELETE). Typically, you’d generate all three.
powersync_create_raw_table_crud_trigger parses the structure of tables from the database schema, so it
must be called after the raw table has been created.
Explicit triggers
Triggers on raw tables can also be defined explicitly instead of usingpowersync_create_raw_table_crud_trigger.
It is your responsibility to setup and migrate these triggers along with the table:
Using foreign keys
Raw tables support advanced table constraints including foreign keys. When enabling foreign keys however, you need to be aware of the following:- While PowerSync will always apply synced data in a transaction, there is no way to control the order in which rows get applied.
For this reason, foreign keys need to be configured with
DEFERRABLE INITIALLY DEFERRED. - When using stream priorities, you need to ensure you don’t have foreign keys from high-priority rows to lower-priority data. PowerSync applies data in one transaction per priority, so these foreign keys would not work.
- As usual when using foreign keys, note that they need to be explicitly enabled with
pragma foreign_keys = on.
Local-Only Columns
Raw tables allow you to add columns that exist only on the client and are never synced to the backend. This is useful for client-specific state like user preferences, local notes, or UI flags that should persist across app restarts but have no equivalent in the backend database.Local-only columns are not supported with PowerSync’s default JSON-based view system. Raw tables are required for this functionality.
todo_lists example above, you can add local-only columns such as is_pinned and local_notes:
With inferred statements and triggers
Both the inferredput and delete statements as well as triggers generated by powersync_create_raw_table_crud_trigger
support local-only columns.
To configure this, include a syncedColumns array on the RawTableSchema:
With explicit statements
The standard raw table setup requires modifications to support local-only columns:Use upsert instead of INSERT OR REPLACE
Theput statement must use INSERT ... ON CONFLICT(id) DO UPDATE SET instead of INSERT OR REPLACE. INSERT OR REPLACE deletes and re-inserts the row, which resets local-only columns to their defaults on every sync update. An upsert only updates the specified synced columns, leaving local-only columns intact.
Only synced columns should be referenced in the put params. Local-only columns are omitted entirely:
Exclude local-only columns from triggers
Thejson_object() in both the INSERT and UPDATE triggers should only reference synced columns. Local-only columns must not appear in the CRUD payload sent to the backend.
Additionally, the UPDATE trigger needs a WHEN clause that checks only synced columns. Without it, changes to local-only columns would fire the trigger and produce unnecessary CRUD entries that get uploaded. The WHEN clause must use IS NOT instead of != for NULL-safe comparisons. NULL != NULL evaluates to NULL in SQLite, which would cause the trigger to skip legitimate changes to nullable synced columns.
Migrations
In PowerSync’s JSON-based view system the client-side schema is applied to the schemaless data, meaning no migrations are required. Raw tables however are excluded from this, so it is the developers responsibility to manage migrations for these tables.Adding raw tables as a new table
When you’re adding new tables to your Sync Streams (or legacy Sync Rules), clients will start to sync data on those tables - even if the tables aren’t mentioned in the client’s schema yet. So at the time you’re introducing a new raw table to your app, it’s possible that PowerSync has already synced some data for that table, which would be stored inps_untyped. When adding regular tables, PowerSync will automatically extract rows from ps_untyped. With raw tables, that step is your responsibility. To copy data, run these statements in a transaction after creating the table:
connect() without them) - you only need this for raw tables you already had locally.
Another workaround is to clear PowerSync data when changing raw tables and opt for a full resync.
Migrating to raw tables
To migrate from PowerSync-managed tables to raw tables, first:- Open the database with the new schema mentioning raw tables. PowerSync will copy data from tables previously managed by PowerSync into
ps_untyped. - Create raw tables.
- Run the
INSERT FROM SELECTstatement to insertps_untypeddata into your raw tables.
Migrations on raw tables
For JSON-based tables, migrations are trivial since all rows are stored as complete JSON objects. Adding or removing columns only affects views over unchanged JSON data, making the schema a stateless structure. For raw tables, the situation is different. When adding a new column for instance, existing rows would not have a default value even if one could have been synced already. Suppose a new column is added with a simple migration:ALTER TABLE todo_list ADD COLUMN priority INTEGER.
This adds the new column on the client, with null values for each existing row.
If the client updates the schema before the server and then syncs the changes, every row effectively
resyncs and reflects populated values for the new column. So clients observe a consistent state after the sync.
If new values have been synced before the client updates, existing rows may not receive the new column
until those rows are synced again! This is why special approaches are needed when migrating synced
tables.
Deleting data on migrations
One option that makes migrations safe (with obvious downsides) is to simply reset the database before migrating:await db.disconnectAndClear(soft: true) deletes materialized sync rows while keeping
downloaded data active. Afterwards, migrations can migrate the schema in any way before you reconnect.
In a soft clear, data doesn’t have to be downloaded again in most cases. This might reduce the downtime
in which no data is available, but a network connection is necessary for data to become
available again.
Triggering resync on migrations
An alternative to the approach of deleting data could be to trigger a re-sync without clearing tables. For example:The _extra column pattern
Another option to avoid data inconsistencies in migrations is to ensure the raw table stores
a full row as expected by PowerSync.
To do that, you can introduce an extra column on your table designed to hold values from the backend
database that a client is not yet aware of:
_extra column is not used in the app, but the sync service can be informed about it using
the Rest column source:
{"created_by": "User", "title": "title", "content": "content", "tags": "Important"},
this put statement would set _extra to {"tags":"Important"}, ensuring that the entire source row
can be recovered from a row in the raw table.
This then allows writing migrations:
- Adding new columns by using
json_extract(_extra, '$.newColumnName')as a default value. - Removing existing columns by updating
_extra = json_set(_extra, '$.droppedColumnName', droppedColumnName)before dropping the column.
ps_crud writes.
Deleting data and raw tables
APIs that clear an entire PowerSync database, like e.g.disconnectAndClear(), don’t affect raw tables by default. You can use the clear parameter on the RawTable constructor to set an SQL statement to run when clearing the database. Typically, something like DELETE FROM $tableName would be a reasonable statement to run.
clear statements are not inferred automatically and must always be set explicitly.
Raw tables themselves are not managed by PowerSync and need to be dropped to delete them.