r/dotnet • u/davidedalcortivo • 1h ago
Promotion Forget v2.4 – CRUD, upsert and multi-row writes on top of Dapper, with the right SQL for MySQL, Oracle, PostgreSQL and SQL Server
Hi r/dotnet,
I'm a senior software developer specializing in .NET, Python and databases. Over the years I've built and maintained many kinds of applications (web apps, services, scheduled jobs, high-concurrency systems), from .NET Framework to modern .NET. I've always worked close to the database and spent a lot of time tuning queries, so I tend to think in SQL first.
In most projects I've seen, the choice was Dapper or EF. I prefer Dapper for its performance and for direct control over the SQL. EF is a good tool, but I've had to fix many apps where it was used without knowing the database underneath (LINQ turning into slow SQL, ToList() before the filters, that kind of thing). With Dapper, though, every new table meant writing the same SQL all over again. I wanted to keep Dapper and lose the boilerplate, with SQL written specifically for each engine.
So I wrote Forget, a thin, provider-aware extension layer on top of Dapper for MySQL, Oracle, PostgreSQL and SQL Server. It does CRUD for one row or many, and upserts on a natural key. Filters can come from a LINQ expression or be built at runtime from property names, and there's sorting and paging too.
var cheapTools = await connection.GetAllAsync<Product>(
p => p.Category == "Tools" && p.Price < 20 && p.IsActive);
await connection.InsertRangeAsync(products, batchSize: 500);
// matches on [UpsertKey] properties, falling back to the [Key]/Id property if none is set
await connection.UpsertAsync(product);
It works on one table at a time, by design. For joins you keep using Dapper on the same connection. There's no change tracking and no migrations either. If you need those, just use EF.
The part I'm most happy with is Oracle. Multi-row VALUES (...), (...) only arrived with Oracle 23ai, and a lot of production databases still run older versions. So I preferred the backward-compatible way that Oracle has always supported: SELECT ... FROM DUAL UNION ALL .... The catch is that it fails with ORA-01790 when the rows have different types for the same column, for example a NULL in one row. Forget reads the column types from ALL_TAB_COLUMNS, finds a cast that Oracle accepts for each column, caches it and applies it to every value. Multi-row inserts, updates and upserts all go through this.
The tests run against real databases (Testcontainers, all 4 engines), including upserts from many connections at the same time.
I also benchmarked it on PostgreSQL against hand-written Dapper and EF Core, and published all the numbers. Forget stays close to plain Dapper; the small overhead comes from validating the values and building the SQL (much of that work is cached). Against EF Core it was faster on single-row operations and on a filtered, paged query, and 2-3x faster on multi-row inserts and updates. Reads by id were as fast or faster when both used a single round trip.
While benchmarking I also found a bug in code I had already shipped. I wrote about the bug and the numbers here: The batch size bug I found while benchmarking
About AI, since someone will ask: I wrote the library code myself, starting in February. Free ChatGPT and Copilot helped with a few small pieces, and I used them to discuss design choices. Later I used Claude to help me with documentation, tests, benchmarks and bug hunting. When a test caught the batch size bug I mentioned above, the AI's first answer was that the test was wrong. I went back to the code anyway, and the test was right.
It's free and open source (Apache-2.0):
- GitHub: https://github.com/davidedalcortivo/Forget
- NuGet: Forget.MySql, Forget.Oracle, Forget.PostgreSql, Forget.SqlServer
Next I'm thinking about SQLite support or real bulk operations (SqlBulkCopy, COPY on PostgreSQL). Which one would you actually use?

