Performance Notes - lobodava/artisan-orm GitHub Wiki
Artisan.Orm is a thin layer over SqlConnection / SqlCommand / SqlDataReader. Most performance characteristics come straight from ADO.NET; this page lists the few decisions that meaningfully affect throughput and latency in real applications.
Production connection strings must keep Pooling=True (the default).
Without pooling, every using var repo = new MyRepo(connStr) performs a full TDS handshake — TLS negotiation, login, SET-options round-trips. That is hundreds of milliseconds, easily, and stacks linearly with concurrency. With pooling, "opening" a connection just borrows a pre-warmed physical connection from a per-process pool — microseconds.
Pooling=False is sometimes useful in tests to surface connection-leak bugs that the pool would mask. Never copy that to production configs.
# Production
Data Source=...;Initial Catalog=...;Integrated Security=True;TrustServerCertificate=True;
^ Pooling defaults to True
# Tests only — see Testing Your Repository
Data Source=...;Pooling=False;...
A repository instance owns one SqlConnection and a mutable Transaction field — sharing it across concurrent operations breaks. Treat it as a unit of work:
- In ASP.NET Core / DI containers — register
Scoped, the framework disposes it at the end of each HTTP request. See Using Artisan.Orm in ASP.NET Core. - In console / scripts —
using var repo = .... - In background workers —
using var scope = _scopeFactory.CreateScope(); ....
new SqlConnection(connStr) is cheap (it's a managed object, no I/O). Don't try to "save" by sharing repositories — you trade microseconds of allocation for hours of debugging concurrency bugs.
ReadTo<T> (registered [MapperFor]) and ReadAs<T> (auto-mapping via expression trees) converge to the same per-row cost after warmup. The difference is:
-
ReadTo<T>runs your hand-writtenCreateObjectdirectly — no warmup, friendly to AOT and trim. -
ReadAs<T>builds the mapper via reflection on the first call for a given (type, column-shape) pair, compiles an expression tree, and caches it. First call: ~1-10 ms one-off. Subsequent calls: same asReadTo<T>.
For long-lived ASP.NET Core processes, the auto-mapping warmup cost amortises to nothing. For short-lived CLI tools, prefer ReadTo<T>.
| Pattern | Memory | Connection lifetime | Use when |
|---|---|---|---|
ReadToList<T> |
Holds the whole result in RAM | Connection closes when the call returns | Small, bounded result sets |
ReadToEnumerable<T> (sync) |
Streams row by row | Connection stays open for the iteration | Large results, sync code |
ReadToAsyncEnumerable<T> |
Streams row by row | Connection stays open for the iteration | Large results, async code |
For a 50-user list-page query, ReadToList<T> is the right answer. For a 5-million-row export, the streaming variants avoid OOM and let downstream pipelines start working immediately.
See Streaming with IAsyncEnumerable for the gotchas around connection lifetime during iteration.
For more than ~50 rows, BulkCopy beats row-by-row insert by 10x-100x. The break-even point depends on row width and indexes, but it's almost always low.
- Loop calling
Save(row)— one round trip per row. -
BulkCopy(rows, "dbo.Table")— one round trip total, streamed over a single TDS frame.
For very large inserts (millions of rows), tune batchSize to avoid filling the transaction log in one go and to allow lock release between batches:
conn.BulkCopy<Record>(rows, "dbo.Records",
options: SqlBulkCopyOptions.TableLock,
batchSize: 5_000);See BulkCopy for the full options.
The MERGE pattern in Negative identities and object graph saving requires:
The source and the target tables MUST have clustered indexes on their join columns.
This isn't a style preference. Without clustered indexes on the join columns, MERGE may:
- deadlock under concurrent writes,
- execute in non-deterministic order (rows may insert before their parents in a multi-table graph save),
- run dramatically slower at scale (hash-match instead of merge-join).
Define the join columns as primary key clustered in the user-defined table types and the target tables. Negative-identity Ids are designed to be ever-increasing exactly so that this constraint holds for newly-inserted rows.
When reading very wide rows (large varbinary(max), large nvarchar(max)), the default CommandBehavior allocates the whole row in memory at Read() time. For multi-megabyte blobs, consider:
-
cmd.ExecuteReader(CommandBehavior.SequentialAccess)— streams column values as you read them, but you must read columns in order and once each. -
dr.GetStream(ord)/dr.GetTextReader(ord)— true streams over the column.
Artisan.Orm doesn't currently expose SequentialAccess as a flag on its high-level methods; for blob-streaming use RunCommand and configure cmd.ExecuteReader directly inside the lambda.
async doesn't make a single call faster. It makes the calling thread available for other work while SQL Server processes the query.
- In ASP.NET Core, prefer
*Asyncon every read/write call. Otherwise the request thread blocks waiting for the database, and under load the thread pool exhausts itself. - In console apps and short scripts, sync is usually fine and slightly easier to debug.
The async path adds a tiny per-call overhead (allocating a state machine, a few continuations). For SQL Server queries that take >1 ms — i.e., basically all of them — it's noise.
Most of the above is "cheap to do right from the start." Beyond those, don't guess — dotnet-trace, SQL Server Profiler, or the Activity tracing on Microsoft.Data.SqlClient will tell you where the time actually goes. Common surprises:
- The slow query is one your code calls 50 times in a loop — fix by reading once.
- Connection pool exhaustion under concurrency — usually a leaked repository instance not being disposed.
- Transactions held open across
awaitpoints — promotes contention; shorten the critical section. - Autocommit on each
Savein a batch — wrap the loop inRunInTransaction.
See also: