---
name: powersync-dotnet
description: PowerSync .NET SDK — schema, queries, sync lifecycle, and backend connectors
metadata:
tags: dotnet, csharp, maui, wpf, console, sqlite, offline-first
---
# PowerSync .NET SDK
> **Load this when** building a .NET app (MAUI, WPF, Console) with PowerSync.
## Table of Contents
- [Installation](#installation)
- [Quick Setup](#quick-setup)
- [Query Patterns](#query-patterns)
- [Writes and Transactions](#writes-and-transactions)
- [Sync Status](#sync-status)
- [Sync Streams](#sync-streams)
- [Logging](#logging)
- [Schema Updates](#schema-updates)
Best practices for building apps with the PowerSync .NET SDK.
Supported targets: .NET 9, .NET 8, .NET 6, .NET Standard 2.0, .NET Framework 4.8. Application frameworks: MAUI (iOS, Android, Windows), WPF, Console.
> **Alpha status** — this SDK is currently in alpha. Expect breaking changes and instability. Do not rely on it for production use.
## Installation
For console, WPF, or general .NET projects:
```bash
dotnet add package PowerSync.Common --prerelease
```
For MAUI projects (also requires `PowerSync.Common`):
```bash
dotnet add package PowerSync.Maui --prerelease
dotnet add package PowerSync.Common --prerelease
```
For .NET Framework 4.8 compatibility, add to your `.csproj`:
```xml
win-x86;win-x64
win-x64
```
## Quick Setup
### 1. Define Schema
There are two ways to define schemas: **dictionary syntax** (simple) and **attribute syntax** (type-safe, with Dapper integration).
#### Dictionary Syntax
```csharp
using PowerSync.Common.DB.Schema;
var todos = new Table
{
Name = "todos",
Columns =
{
["description"] = ColumnType.Text,
["list_id"] = ColumnType.Text,
["completed"] = ColumnType.Integer, // booleans as INTEGER (0/1)
["created_at"] = ColumnType.Text, // dates as ISO TEXT
},
Indexes =
{
["list_idx"] = ["list_id"],
}
};
var lists = new Table
{
Name = "lists",
Columns =
{
["name"] = ColumnType.Text,
["owner_id"] = ColumnType.Text,
["created_at"] = ColumnType.Text,
}
};
var schema = new Schema(todos, lists);
```
#### Attribute Syntax
```csharp
using PowerSync.Common.DB.Schema.Attributes;
[Table("todos"), Index("list_idx", ["list_id"])]
public class TodoItem
{
[Column("id")]
public string Id { get; set; } = "";
[Column("description")]
public string Description { get; set; } = "";
[Column("list_id")]
public string ListId { get; set; } = "";
[Column("completed")]
public bool Completed { get; set; } // bool maps to INTEGER automatically
[Column("created_at")]
public string CreatedAt { get; set; } = "";
}
[Table("lists")]
public class TodoList
{
[Column("id")]
public string Id { get; set; } = "";
[Column("name")]
public string Name { get; set; } = "";
[Column("owner_id")]
public string OwnerId { get; set; } = "";
[Column("created_at")]
public string CreatedAt { get; set; } = "";
}
// Build schema from attributed types
var schema = new Schema(typeof(TodoItem), typeof(TodoList));
// Or mix: new Table(typeof(TodoItem)) also works
```
With attribute syntax, an `id` property of type `string` is **required** on each class but is not added as a column — PowerSync adds `id TEXT PRIMARY KEY` automatically. The `[Column("id")]` attribute maps the property for Dapper deserialization.
Column types: `ColumnType.Text`, `ColumnType.Integer`, `ColumnType.Real` only — no boolean, date, or JSON native types. With attributes, `ColumnType.Inferred` auto-maps C# types (`string` → Text, `bool`/`int`/`long`/`enum` → Integer, `float`/`double` → Real, `decimal`/`DateTime`/`Guid` → Text).
No migrations required. Schema changes apply on next open. New columns start null; removed columns become inaccessible (data remains). Renaming = add new + remove old (data loss).
### Special Table Options
```csharp
// Local-only — not synced, not uploaded, persists across restarts
var drafts = new Table("drafts",
new Dictionary { ["content"] = ColumnType.Text },
new TableOptions(localOnly: true));
// Insert-only — writes uploaded, server never sends data back
var logs = new Table("logs",
new Dictionary { ["message"] = ColumnType.Text },
new TableOptions(insertOnly: true));
// Track previous values — available as entry.PreviousValues in UploadData
var todos = new Table("todos",
new Dictionary
{
["description"] = ColumnType.Text,
["completed"] = ColumnType.Integer,
},
new TableOptions(
trackPreviousValues: new TrackPreviousOptions
{
Columns = new List { "completed" }, // null = track all columns
OnlyWhenChanged = true
}
));
// Track write metadata — adds _metadata column, available as entry.Metadata in UploadData
var tasks = new Table("tasks",
new Dictionary { ["title"] = ColumnType.Text },
new TableOptions(trackMetadata: true));
// Ignore no-op updates (UPDATE that changes no values)
var items = new Table("items",
new Dictionary { ["name"] = ColumnType.Text },
new TableOptions(ignoreEmptyUpdates: true));
```
With attribute syntax:
```csharp
[Table("todos", TrackPreviousValues = TrackPrevious.Columns | TrackPrevious.OnlyWhenChanged)]
[Index("list_idx", ["list_id"])]
public class TodoItem
{
[Column("id")]
public string Id { get; set; } = "";
[Column("completed", TrackPrevious = true)] // only this column tracked
public bool Completed { get; set; }
[Column("description")]
public string Description { get; set; } = "";
}
[Table("logs", InsertOnly = true)]
public class LogEntry { /* ... */ }
[Table("drafts", LocalOnly = true)]
public class Draft { /* ... */ }
[Table("tasks", TrackMetadata = true)]
public class Task { /* ... */ }
[Table("items", IgnoreEmptyUpdates = true)]
public class Item { /* ... */ }
```
### 2. Create Backend Connector
```csharp
using PowerSync.Common.Client;
using PowerSync.Common.Client.Connection;
using PowerSync.Common.DB.Crud;
public class MyConnector : IPowerSyncBackendConnector
{
public async Task FetchCredentials()
{
// Always fetch fresh credentials — do not cache stale tokens
var token = await myAuthService.GetTokenAsync();
return new PowerSyncCredentials(
endpoint: "https://your-instance.powersync.journeyapps.com",
token: token
);
}
public async Task UploadData(IPowerSyncDatabase database)
{
var transaction = await database.GetNextCrudTransaction();
if (transaction == null) return;
try
{
foreach (var entry in transaction.Crud)
{
switch (entry.Op)
{
case UpdateType.PUT:
await api.Upsert(entry.Table, entry.Id, entry.OpData!);
break;
case UpdateType.PATCH:
await api.Update(entry.Table, entry.Id, entry.OpData!);
break;
case UpdateType.DELETE:
await api.Delete(entry.Table, entry.Id);
break;
}
}
await transaction.Complete(); // MUST call — otherwise the same transaction is returned forever
}
catch (Exception ex)
{
throw; // PowerSync backs off and retries automatically
}
}
}
```
**Fatal upload errors**: If `UploadData` always throws for a bad record, the queue stalls permanently. Detect unrecoverable errors (e.g. constraint violations) and call `await transaction.Complete()` to discard them.
#### CrudEntry Fields
```csharp
entry.Id // string — row ID
entry.Op // UpdateType — PUT | PATCH | DELETE
entry.OpData // Dictionary? — changed columns (null for DELETE)
entry.Table // string — table name
entry.TransactionId // long? — groups ops from the same WriteTransaction()
entry.PreviousValues // Dictionary? — requires TrackPreviousValues on table
entry.Metadata // string? — requires TrackMetadata on table
entry.ClientId // int — internal client ID for the CRUD entry
```
Op semantics: `PUT` = full insert/replace (all non-null columns), `PATCH` = partial update (changed columns only), `DELETE` = deletion (`OpData` is null).
For batching multiple transactions at once, use `database.GetCrudBatch(limit)`. Both `CrudBatch` and `CrudTransaction` follow the same `Complete()` contract.
### 3. Initialize and Connect
#### Console / WPF
```csharp
using PowerSync.Common.Client;
var db = new PowerSyncDatabase(new PowerSyncDatabaseOptions
{
Database = new SQLOpenOptions { DbFilename = "app.db" },
Schema = schema,
});
await db.Init();
// Starts sync stream and UploadData loop in the background (non-blocking)
await db.Connect(connector);
// Wait for first sync before showing data-dependent UI
await db.WaitForFirstSync();
```
#### MAUI
```csharp
using PowerSync.Common.Client;
using PowerSync.Common.MDSQLite;
using PowerSync.Maui.SQLite;
var dbPath = Path.Combine(FileSystem.AppDataDirectory, "app.db");
var factory = new MAUISQLiteDBOpenFactory(new MDSQLiteOpenFactoryOptions
{
DbFilename = dbPath
});
var db = new PowerSyncDatabase(new PowerSyncDatabaseOptions
{
Database = factory,
Schema = schema,
});
await db.Init();
await db.Connect(connector);
```
Use a **single `PowerSyncDatabase` instance** per database file. Multiple instances for the same file cause lock contention and missed watch updates — share via dependency injection.
#### Connection Options
```csharp
await db.Connect(connector, new PowerSyncConnectionOptions(
@params: new Dictionary { ["userId"] = "abc123" }, // sync parameters
retryDelayMs: 5000,
crudUploadThrottleMs: 1000,
appMetadata: new Dictionary { ["app_version"] = "1.0.0" }
));
```
#### Disconnect and Clear
```csharp
await db.Disconnect(); // stop syncing, keep local data
await db.DisconnectAndClear(); // stop syncing, wipe synced tables (e.g. on sign-out)
await db.DisconnectAndClear(clearLocal: false); // wipe synced data but preserve local-only tables
await db.Close(); // release resources — cannot be reused after this
```
`Disconnect()` — temporary offline, token refresh, app backgrounding. Safe to reconnect as the same user. `DisconnectAndClear()` — user sign-out or account switch, prevents stale data leaking to the next user. `Close()` — app termination, instance cannot be reused after this call.
## Query Patterns
The .NET SDK uses [Dapper](https://github.com/DapperLib/Dapper) for result mapping. Query results are mapped to record types, classes, or `dynamic`.
### Watch Queries (Reactive)
Watch returns an `IAsyncEnumerable` that emits new results whenever dependent tables change.
```csharp
public record TodoResult(string id, string description, string list_id, int completed);
// Watch returns IAsyncEnumerable — call synchronously to capture table listener
var watcher = db.Watch(
"SELECT * FROM todos WHERE list_id = ? ORDER BY id",
parameters: [listId]
);
// Consume asynchronously
await foreach (var todos in watcher)
{
// todos is TodoResult[] — updated on every relevant table change
UpdateUI(todos);
}
```
With cancellation:
```csharp
var cts = new CancellationTokenSource();
var watcher = db.Watch(
"SELECT * FROM todos",
options: new SQLWatchOptions { Signal = cts.Token }
);
_ = Task.Run(async () =>
{
await foreach (var results in watcher)
{
UpdateUI(results);
}
}, cts.Token);
// Later: stop watching
cts.Cancel();
```
React to table changes without re-running a query:
```csharp
var onChange = db.OnChange(new SQLWatchOptions { Tables = ["todos", "lists"] });
await foreach (var e in onChange)
{
Console.WriteLine($"Changed tables: {string.Join(", ", e.ChangedTables)}");
}
```
### One-Time Queries
```csharp
// GetAll — returns T[]
var todos = await db.GetAll(
"SELECT * FROM todos WHERE list_id = ?", [listId]);
// Get — returns T, throws if not found
var todo = await db.Get(
"SELECT * FROM todos WHERE id = ?", [id]);
// GetOptional — returns T? (null if not found)
var todo = await db.GetOptional(
"SELECT * FROM todos WHERE id = ?", [id]);
// Dynamic results (no type parameter)
var rows = await db.GetAll("SELECT * FROM todos");
```
### Result Mapping
With dictionary syntax, use records/classes whose property names match column names:
```csharp
// Simple record — property names must match column names exactly
public record ListResult(string id, string name, string owner_id, string created_at);
var lists = await db.GetAll("SELECT * FROM lists");
```
With attribute syntax, `[Column("column_name")]` maps columns to C# properties and Dapper handles the mapping automatically:
```csharp
// Using the attributed TodoItem class defined in the schema section
var items = await db.GetAll("SELECT * FROM todos");
```
## Writes and Transactions
```csharp
// Single operation — uuid() is a PowerSync built-in SQLite function
await db.Execute(
"INSERT INTO lists (id, created_at, name, owner_id) VALUES (uuid(), datetime(), ?, ?)",
["My List", userId]);
// Multiple operations atomically — auto-commits, auto-rollbacks on exception
await db.WriteTransaction(async tx =>
{
await tx.Execute("DELETE FROM lists WHERE id = ?", [listId]);
await tx.Execute("DELETE FROM todos WHERE list_id = ?", [listId]);
});
// Write transaction with return value
var count = await db.WriteTransaction(async tx =>
{
await tx.Execute("INSERT INTO lists (id, name, owner_id) VALUES (uuid(), ?, ?)", ["New", userId]);
var result = await tx.Get("SELECT count(*) as count FROM lists");
return result.count;
});
```
ID generation — every table has `id TEXT PRIMARY KEY` added automatically:
```csharp
await db.Execute("INSERT INTO todos (id, description) VALUES (uuid(), ?)", ["Buy milk"]);
// or generate in C#:
await db.Execute("INSERT INTO todos (id, description) VALUES (?, ?)", [Guid.NewGuid().ToString(), "Buy milk"]);
```
### Batch Execution
```csharp
// Execute the same statement with multiple parameter sets
await db.ExecuteBatch(
"INSERT INTO todos (id, description, list_id) VALUES (uuid(), ?, ?)",
[
["Buy milk", listId],
["Buy eggs", listId],
["Buy bread", listId],
]);
```
## Sync Status
```csharp
SyncStatus status = db.CurrentStatus;
status.Connected // bool — sync stream is active
status.Connecting // bool
status.DataFlowStatus.Downloading // bool
status.DataFlowStatus.Uploading // bool
status.HasSynced // bool? — null = DB still opening; true = at least one full sync completed
status.LastSyncedAt // DateTime?
status.DownloadProgress() // SyncProgress? — non-null only while downloading
status.DataFlowStatus.UploadError // Exception?
status.DataFlowStatus.DownloadError // Exception?
```
Observe status changes via event stream:
```csharp
await foreach (var update in db.ListenAsync(cancellationToken))
{
if (update.StatusChanged != null)
{
var s = update.StatusChanged;
Console.WriteLine($"Connected: {s.Connected}, HasSynced: {s.HasSynced}");
}
}
```
`HasSynced` persists across app restarts once set.
### Sync Priorities
Buckets can be assigned priorities (lower number = higher priority) on the server. Higher-priority data syncs first. See [Prioritized Sync](https://docs.powersync.com/usage/use-case-examples/prioritized-sync.md).
```csharp
await db.WaitForFirstSync(new PowerSyncDatabase.PrioritySyncRequest { Priority = 1 });
var entry = db.CurrentStatus.StatusForPriority(1);
entry.HasSynced // bool?
entry.LastSyncedAt // DateTime?
var progress = db.CurrentStatus.DownloadProgress()?.UntilPriority(1);
progress?.DownloadedFraction // double 0.0–1.0
progress?.DownloadedOperations // int
progress?.TotalOperations // int
```
## Sync Streams
Sync Streams are the recommended way to define what data syncs to each client. See [Sync Config](references/sync-config.md) for server-side configuration (YAML definitions, parameters, CTEs) and [Client-Side Usage](https://docs.powersync.com/sync/streams/client-usage.md) for full examples.
> Sync Streams are currently in alpha in the .NET SDK.
If `auto_subscribe` is not set to `true` in the sync config, subscribe to streams from client code:
```csharp
using PowerSync.Common.Client.Sync.Stream;
// Create a stream handle
ISyncStream stream = db.SyncStream(
name: "my_orders",
parameters: new Dictionary { ["userId"] = currentUserId }
);
// Subscribe to the stream
ISyncStreamSubscription subscription = await stream.Subscribe(new SyncStreamSubscribeOptions
{
Ttl = TimeSpan.FromHours(1), // optional — keep data alive after unsubscribe
Priority = new StreamPriority(1), // optional — lower number = higher priority
});
// Wait for this specific stream to complete its first sync
await subscription.WaitForFirstSync();
// Check stream status
SyncStreamStatus? streamStatus = db.CurrentStatus.ForStream(subscription);
streamStatus?.Subscription.HasSynced // bool
streamStatus?.Subscription.LastSyncedAt // DateTime?
streamStatus?.Subscription.Active // bool
streamStatus?.Subscription.IsDefault // bool
streamStatus?.Subscription.ExpiresAt // DateTime?
streamStatus?.Progress?.DownloadedFraction // double
// Unsubscribe when done — TTL starts running after this
subscription.Unsubscribe();
// Or unsubscribe all subscriptions for a stream
await stream.UnsubscribeAll();
```
Same stream name with different parameters creates separate subscriptions. Subscribing while offline is supported — subscriptions are tracked locally and sent on next connect.
## Logging
The SDK accepts an `ILogger` via `PowerSyncDatabaseOptions`:
```csharp
using Microsoft.Extensions.Logging;
ILoggerFactory loggerFactory = LoggerFactory.Create(builder =>
{
builder.AddConsole();
builder.SetMinimumLevel(LogLevel.Debug);
});
var logger = loggerFactory.CreateLogger("PowerSync");
var db = new PowerSyncDatabase(new PowerSyncDatabaseOptions
{
Database = new SQLOpenOptions { DbFilename = "app.db" },
Schema = schema,
Logger = logger,
});
```
## Schema Updates
Update the schema at runtime (must be disconnected):
```csharp
await db.Disconnect();
await db.UpdateSchema(newSchema);
await db.Connect(connector);
```
## Additional Resources
Only read these if the content above does not provide enough context for the task.
- [NuGet packages](https://www.nuget.org/profiles/PowerSync) — published packages
- [Full SDK reference](https://docs.powersync.com/client-sdk-references/dotnet.md) — full SDK documentation
- [CommandLine](https://github.com/powersync-ja/powersync-dotnet/tree/main/demos/CommandLine) — CLI app with real-time data sync example
- [WPF](https://github.com/powersync-ja/powersync-dotnet/tree/main/demos/WPF) — Windows desktop to-do list app example
- [MAUITodo](https://github.com/powersync-ja/powersync-dotnet/tree/main/demos/MAUITodo) — Cross-platform mobile and desktop to-do list example