Pour millions of rows into PostgreSQL at binary-
COPYspeed - a fluent, strongly-typed mapping API over Npgsql. NoDataTable, no hand-rolled writers, justCOPY ... FROM STDIN BINARYdoing what it does best, for a few percent more than writing that writer yourself.
A small, focused wrapper around Npgsql's binary import. Map
each entity property to a PostgreSQL column with a type-specific Map* method,
then stream an IEnumerable<TEntity> - or an IAsyncEnumerable<TEntity> -
straight into the server. The library writes the binary COPY protocol for
you, quotes every identifier, and leaves the connection exactly as it found it.
COPY ... FROM STDIN BINARY is the fastest way to load many rows into
PostgreSQL, but Npgsql exposes it as a low-level writer: you open the importer,
start each row, and push every value with its NpgsqlDbType by hand. That is
fast but easy to get wrong - one mismatched type or forgotten null and the copy
fails mid-stream. This library closes that gap:
- Stream, don't stage. Rows flow through the binary importer one at a time, so a million-row insert never materializes a million-row buffer in memory.
- Map to PostgreSQL types, not magic strings.
MapJsonb,MapMoney,MapUUID,MapTimeStampTz,MapInetAddress- each method binds the rightNpgsqlDbType, so the wire format matches the column. - Let nulls just work. A mapped getter that returns
nullwrites a SQLNULL; everything else is written with its declared type. Nullable value types map naturally. - Stay safe and in control. Every table, schema, and column name is quoted
per PostgreSQL rules, duplicate columns are rejected up front, and a
CancellationTokenflows all the way through the copy. - Pay almost nothing for it. The convenience is not the cost: every release is measured against the hand-written loop it replaces.
- Fluent builder -
CreateBulkContext→Map*→WriteDataAsync. - Type-specific column mapping for the PostgreSQL type families: text
(
MapText,MapVarchar,MapCharacter), numeric (MapSmallInt,MapInteger,MapBigInt,MapNumeric,MapReal,MapDouble), monetary (MapMoney), boolean (MapBoolean), binary (MapByteArray), JSON (MapJson,MapJsonb), UUID (MapUUID), date/time (MapDate,MapTime,MapTimeTz,MapTimeStamp,MapTimeStampTz,MapInterval), and network addresses (MapInetAddress,MapMacAddress). - Synchronous and asynchronous sources -
WriteDataAsyncaccepts bothIEnumerable<TEntity>andIAsyncEnumerable<TEntity>, and the async one costs no more than the sync one. - Null-aware writes - a getter returning
nullemits a SQLNULL; no sentinel values, no special casing. - Safe identifier quoting - table, schema, and column names are wrapped and escaped per PostgreSQL rules, so names with special characters or mixed case work and identifier injection does not.
- Managed connection lifecycle - a closed connection is opened for the copy and closed again afterwards, leaving it as it was found; one that is busy with another command is rejected before the copy starts.
- Misuse fails fast - an unmapped builder, a null argument, a blank table name and a busy connection each throw before a single byte reaches the wire.
- Cancellation - pass a
CancellationTokentoWriteDataAsync; it reaches everyawaitalong the copy. - Multi-targets
net8.0,net9.0, andnet10.0.
dotnet add package PetToys.DbAssistant.PostgresDescribe how each entity property maps to a column, then write the data:
using Npgsql;
using PetToys.DbAssistant.Postgres;
using PetToys.DbAssistant.Postgres.Extensions;
await using var connection = new NpgsqlConnection(connectionString);
ulong rowsCopied = await connection.CreateBulkContext<BusinessEntity>("records")
.MapInteger("id", e => e.Id)
.MapText("name", e => e.Name)
.MapJsonb("payload", e => e.Payload)
.MapMoney("price", e => e.Price)
.MapTimeStampTz("created_at", e => e.CreatedAt)
.WriteDataAsync(entities);WriteDataAsync opens the connection if it is closed, runs the binary COPY,
closes the connection again if it opened it, and returns the number of rows
written.
Each Map* call adds one column to the copy. The first argument is the column
name; the lambda projects the value out of the entity. Pick the method that
matches the destination column's PostgreSQL type:
await connection.CreateBulkContext<BusinessEntity>("records")
.MapInteger("id", e => e.Id) // integer
.MapText("name", e => e.Name) // text
.MapNumeric("amount", e => e.Amount) // numeric
.MapUUID("ref", e => e.Reference) // uuid
.WriteDataAsync(entities);A column name may be mapped only once. Mapping the same column twice throws an
InvalidOperationException naming the duplicated column. Comparison is
case-sensitive, matching PostgreSQL quoted-identifier semantics, so "Name"
and "name" are two distinct columns.
Make the mapped getter return a nullable type. When it yields null, the
column receives a SQL NULL; otherwise the value is written with its mapped
type:
await connection.CreateBulkContext<BusinessEntity>("records")
.MapInteger("id", e => e.Id)
.MapText("note", e => e.Note) // string? -> NULL when the note is null
.MapByteArray("blob", e => e.Blob) // byte[]? -> NULL when absent
.WriteDataAsync(entities);When rows arrive from an asynchronous producer - a paged query, a channel, a
stream - pass the IAsyncEnumerable<TEntity> directly. Nothing is buffered:
async IAsyncEnumerable<BusinessEntity> ReadAsync() { /* yield rows */ }
ulong rowsCopied = await connection.CreateBulkContext<BusinessEntity>("records")
.MapInteger("id", e => e.Id)
.MapText("name", e => e.Name)
.WriteDataAsync(ReadAsync(), cancellationToken);Pass a schema name to target a table outside the default search path; both the schema and the table are quoted independently:
await connection.CreateBulkContext<BusinessEntity>("records", schemaName: "analytics")
.MapInteger("id", e => e.Id)
.WriteDataAsync(entities); // COPY "analytics"."records"(...) FROM STDIN BINARYWriteDataAsync takes a CancellationToken that flows through every step of
the copy:
await connection.CreateBulkContext<BusinessEntity>("records")
.MapInteger("id", e => e.Id)
.WriteDataAsync(entities, cancellationToken);The mapping is the convenience; the point is that it is not the cost. Every
release is measured against the hand-written NpgsqlBinaryImporter loop this
library exists to replace: the same values, in the same order, with the same
NpgsqlDbType, into the same table, with only the loop changing hands.
From the recorded baseline, one copy of 100,000 rows:
| Copy of 100,000 rows | Compared against | Baseline | Measured | Ratio |
|---|---|---|---|---|
| Four-column row | hand-written importer | 48.59 ms | 52.43 ms | 1.08x |
| Twelve-column row | hand-written importer | 241.53 ms | 249.70 ms | 1.04x |
| Async source, four columns | IEnumerable source |
52.47 ms | 52.99 ms | 1.01x |
- The wider the row, the smaller the share. The overhead is per row and per column, while the server's part of a copy grows faster than either - so tripling the columns dilutes the mapping instead of multiplying it.
- The extra allocation is per copy, not per row. 0.63 KB on the narrow row and 7.03 KB on the wide one at 100,000 rows: the builder, the column list and the delegates, allocated once. Per row both arms allocate the same, and at that size the gap is under 0.2% of what the copy allocates in total.
- Ratios travel, milliseconds do not. Those durations come from one laptop,
one
postgres:18-alpinecontainer over a loopback, andUNLOGGEDdestination tables - a floor, not the cost of a copy into your own indexed table. Compare a run of your own by its ratio. - Load into a staging table for the best throughput. Copy into an unindexed temporary or staging table first, then insert from there into the indexed target. That keeps the copy itself as cheap as it can be, and it is worth far more than the few percent above.
The benchmark project runs on any machine with a Docker
engine, or against a server of your own; BASELINE.md is the
run quoted here, environment header and all. Nothing in the build gates on
these numbers - they are a measurement, not a promise.
- The connection is left as it was found. A connection that is already open stays open; a closed one is opened for the copy and closed again afterwards. A broken one is opened the same way - Npgsql reconnects it - and closed again, which also means it comes back without whatever session state it carried: a temporary table created before the break is gone.
- A busy connection is rejected, not queued. A connection executing another
command or holding an open reader fails with an
InvalidOperationExceptionnaming that state, before the copy starts, instead of surfacing as an error from inside it. Run the copy on a connection of its own. - Arguments are validated up front.
CreateBulkContextthrowsArgumentNullExceptionfor a null connection andArgumentExceptionfor a null or whitespace table or schema name;WriteDataAsyncthrowsArgumentNullExceptionfor a null collection - before the connection is ever opened. - A copy needs at least one mapped column. If no
Map*call was made,WriteDataAsyncthrowsInvalidOperationExceptionwithout touching the connection, rather than reporting a silent no-op as0rows written. - Identifiers are quoted, not sanitized. Names are wrapped in double quotes
and embedded quotes are doubled, so
My"Tablebecomes"My""Table". Supply the raw name; the library never strips quoting you add yourself. timestamptzfrom aDateTimemust be UTC.MapTimeStampTzwith aDateTimegetter accepts onlyDateTimeKind.Utc; aLocalorUnspecifiedvalue fails the copy with an error naming the column. Convert to UTC (DateTime.ToUniversalTime()), or map from aDateTimeOffset- theMapTimeStampTz(..., Func<TEntity, DateTimeOffset?>)overload carries the offset for you.
More runnable examples live in the unit tests.
- Array columns,
DateOnlyandTimeOnlymapping, and read-only access to the columns a context has mapped. - Binary
COPYexport from a table to a stream, and CSV in both directions. - An upsert helper around the staging-table pattern.
- Whatever the next real use case calls for.
This package is built for its author's own needs; feature requests and pull requests are welcome. See Contributing to get started.
Provided under the Apache License, Version 2.0.
