Writeback 0.1.0
dotnet add package Writeback --version 0.1.0
NuGet\Install-Package Writeback -Version 0.1.0
<PackageReference Include="Writeback" Version="0.1.0" />
<PackageVersion Include="Writeback" Version="0.1.0" />
<PackageReference Include="Writeback" />
paket add Writeback --version 0.1.0
#r "nuget: Writeback, 0.1.0"
#:package Writeback@0.1.0
#addin nuget:?package=Writeback&version=0.1.0
#tool nuget:?package=Writeback&version=0.1.0
Writeback
The write side of Dapper, done right on SQL Server, PostgreSQL, MySQL and SQLite. Insert, update and delete
with every database-generated value written back, optimistic concurrency, and honest batching. Reads stay plain
Dapper and plain SQL. No change tracking, no LINQ, no DbContext.
[Table("customer")]
public class Customer
{
public long Id { get; set; } // key + identity by convention
public string Name { get; set; } = "";
[DatabaseGenerated(DatabaseGeneratedOption.Identity)]
public DateTime CreatedAt { get; set; } // DEFAULT now(): read back after insert
[ConcurrencyCheck]
public int Version { get; set; } // optimistic concurrency, portable
[NotMapped]
public string UiOnlyValue { get; set; } = ""; // never persisted
}
var customer = new Customer { Name = "Ada" };
await db.InsertAsync(customer); // customer.Id and customer.CreatedAt are now set
customer.Name = "Ada Lovelace";
await db.UpdateAsync(customer); // WHERE "Version" = @Version; Version is now 1
await db.UpdateAsync(customer, c => new { c.Name }); // only the columns you name
await db.InsertManyAsync(customers); // batched, one transaction, every Id written back
await db.DeleteAsync(customer);
var found = await db.GetAsync<Customer>(42);
var active = await db.SelectAsync<Customer>("WHERE \"Name\" LIKE @p ORDER BY \"Id\"", new { p = "A%" });
Those attributes are standard System.ComponentModel.DataAnnotations, the same ones EF Core reads, so your
entities don't reference this library.
Why not just Dapper?
Because the write path is where hand-written Dapper code goes subtly wrong, and it goes wrong differently on each database:
| You want | By hand with Dapper | With Writeback |
|---|---|---|
| The generated key and defaults, computed columns, rowversion | different SQL per database: OUTPUT, RETURNING, LAST_INSERT_ID() + re-select |
InsertAsync picks the right one |
| …on a SQL Server table with triggers | OUTPUT fails (error 334) and would show pre-trigger values anyway |
[HasTriggers] → trigger-safe re-select that sees trigger-written values |
| Insert 10,000 rows and get their keys | Execute(sql, list) = 10,000 round trips, no keys (also what Dapper.Contrib and Dommel do) |
chunked to the parameter limit, one transaction, every key correlated exactly (never by row order) |
| Detect lost updates | write WHERE version = @v, check counts, handle NOCOUNT and trigger-inflated row counts |
[ConcurrencyCheck] or [Timestamp] → ConcurrencyConflictException |
| Update two columns | hand-write the UPDATE |
UpdateAsync(e, x => new { x.A, x.B }) |
| Rename a column | grep string literals | [Column("full_name")]; generated SQL aliases it, and db.Sql<T>() fragments keep your own SQL in sync |
| See exactly what runs | (it's your SQL) | EntitySql.For<Customer>(SqlDialect.PostgreSql).Insert, deterministic and assertable in tests |
Each one sounds small, but this repository's integration tests exist because each was found wrong at least once, including in this project's own first draft. The full argument is in the product thesis.
Install
dotnet add package Writeback # SQL Server, PostgreSQL, MySQL, SQLite: dialects need no driver reference
dotnet add package Writeback.SqlServer # optional: BulkCopyAsync via SqlBulkCopy
dotnet add package Writeback.PostgreSql # optional: BulkCopyAsync via binary COPY
The dialect is detected from the connection type: SqlConnection (both clients), NpgsqlConnection,
MySqlConnection (MySqlConnector or MySql.Data), and SqliteConnection (Microsoft.Data.Sqlite or
System.Data.SQLite). Wrappers like MiniProfiler are unwrapped. Anything else takes one line:
o.UseDialect<MyConnection>(SqlDialect.PostgreSql).
A two-minute tour
// Optional, once at startup: conventions and fluent mapping for types you don't annotate.
WritebackConfig.Configure(o =>
{
o.NamingConvention = NamingConvention.SnakeCase; // CreatedAt -> created_at
o.Entity<Invoice>(e =>
{
e.ToTable("invoices", schema: "billing");
e.HasKey(x => new { x.Year, x.Number }); // composite key
e.Property(x => x.Total).ValueGeneratedOnInsertAndUpdate();
e.HasTriggers(); // SQL Server: no OUTPUT on this table
});
});
// Composite keys
var invoice = await db.GetAsync<Invoice>(new { Year = 2026, Number = 17 });
// Conflicts are exceptions, never silent
try { await db.UpdateAsync(staleCopy); }
catch (ConcurrencyConflictException ex) { /* ex.Entities: who conflicted */ }
// Batches are all-or-nothing; entities change only after commit
await db.UpdateManyAsync(invoices);
await db.DeleteManyAsync(oldInvoices);
var several = await db.GetManyAsync<Customer>(new[] { 1L, 2L, 3L });
// Querying stays SQL; the mapping gives you correctly quoted, aliased fragments
var c = db.Sql<Customer>();
var rows = await db.QueryAsync<Customer>(
$"SELECT {c.ColumnsOf("c")} FROM {c.Table} c WHERE {c.Column(x => x.Email)} = @email", new { email });
await foreach (var x in db.SelectUnbufferedAsync<Customer>("ORDER BY \"Id\"")) { /* streamed */ }
// Millions of rows, no read-back: the provider's bulk protocol
await sqlConnection.BulkCopyAsync(rows); // Writeback.SqlServer
Run the whole thing against in-memory SQLite: dotnet run --project samples/Writeback.Samples.
What it deliberately doesn't do
LINQ queries, relationships/navigation properties, change tracking, identity maps, unit of work, lazy loading, and migrations. Those are EF Core's job, and EF Core is good at them. Here, every method maps to one SQL statement (or one batch per chunk) that you can print. Loading and saving aggregates explicitly takes a few lines: see the recipes.
Supported
| SQL Server 2016+ | PostgreSQL 12+ | MySQL 8.0+ | SQLite 3.35+ | |
|---|---|---|---|---|
| Generated values on insert | OUTPUT / trigger-safe re-select |
RETURNING |
LAST_INSERT_ID() re-select |
RETURNING |
| Generated values on update | OUTPUT / re-select |
RETURNING |
re-select | RETURNING |
| Batch insert with keys | MERGE + position |
statement per row, 1 round trip | statement per row, 1 round trip | prepared statement, 1 transaction |
| Bulk copy (no read-back) | SqlBulkCopy |
binary COPY |
none | none |
| Integration-tested here | 2022 | 18 | 8.4 | bundled |
The library targets .NET 8 and .NET 10, async-only with CancellationToken throughout, and depends only on Dapper.
Performance
These numbers are in-process (SQLite), so they measure library overhead, not network time:
GetAsync: at parity with hand-written Dapper.InsertAsync: about 16 µs more than hand-written Dapper, which is the cost of reading back generated values.InsertManyAsync, 1,000 rows with every key written back: 12 ms, against 73 ms for EF Core 10.- Allocations: about 25× less than EF Core per single-row operation.
Over a network, InsertManyAsync needs one round trip per chunk, where Execute(list) needs one per row.
Methodology, tables and caveats are in docs/BENCHMARKS.md.
Documentation
| Product thesis | why this exists, for whom, and what it refuses to be |
| Ecosystem research | Dapper, Contrib, Dommel, RepoDb, Dapper Plus, EF Core; database capabilities |
| Architecture | metadata → statements → dialects → execution; adding a database |
| Decisions (ADRs) | the 14 decisions that shape the public API |
| Self-review | what an external review found and how it was fixed |
| Recipes | aggregates, many-to-many, paging, upsert, type handlers, DI, bulk |
| Benchmarks | raw Dapper vs Writeback vs EF Core |
| Migration | from the original 2017 DapperHelper API |
| Releasing | publishing to nuget.org with Trusted Publishing |
Building and testing
dotnet build Writeback.slnx
dotnet test --project tests/Writeback.Tests
dotnet test --project tests/Writeback.IntegrationTests
- Unit tests: mapping rules and exact SQL per dialect. No database needed.
- Integration tests: one behavioral contract run against SQLite, PostgreSQL, MySQL and SQL Server, using
Testcontainers (Docker). Without Docker the server suites are skipped, not failed. Set
WRITEBACK_POSTGRES,WRITEBACK_SQLSERVERorWRITEBACK_MYSQLto use existing servers instead. - Benchmarks:
dotnet run -c Release --project benchmarks/Writeback.Benchmarks -- --filter '*'
History
Writeback was called DapperExtension until October 2026 (why it was renamed). The original 2017 helper lives in git history; docs/MIGRATION.md maps its API to this one.
License
| Product | Versions Compatible and additional computed target framework versions. |
|---|---|
| .NET | net8.0 is compatible. net8.0-android was computed. net8.0-browser was computed. net8.0-ios was computed. net8.0-maccatalyst was computed. net8.0-macos was computed. net8.0-tvos was computed. net8.0-windows was computed. net9.0 was computed. net9.0-android was computed. net9.0-browser was computed. net9.0-ios was computed. net9.0-maccatalyst was computed. net9.0-macos was computed. net9.0-tvos was computed. net9.0-windows was computed. net10.0 is compatible. net10.0-android was computed. net10.0-browser was computed. net10.0-ios was computed. net10.0-maccatalyst was computed. net10.0-macos was computed. net10.0-tvos was computed. net10.0-windows was computed. |
NuGet packages (2)
Showing the top 2 NuGet packages that depend on Writeback:
| Package | Downloads |
|---|---|
|
Writeback.SqlServer
SqlBulkCopy-based bulk loading for Writeback entities. |
|
|
Writeback.PostgreSql
Binary COPY bulk loading for Writeback entities on PostgreSQL. |
GitHub repositories
This package is not used by any popular GitHub repositories.
| Version | Downloads | Last Updated |
|---|---|---|
| 0.1.0 | 52 | 10/1/2026 |