Introducing RepoDb.ClickHouse — RepoDB’s Provider for ClickHouse Analytics Workloads
Published:
We just announced preview support for ClickHouse — read that first if you haven’t yet. This post goes one level deeper: how RepoDb.ClickHouse and RepoDb.ClickHouse.BulkOperations are built, why ClickHouse needed more than a drop-in provider swap, and enough code to get productive with it right away.
Status: Early development (v0.0.1-alpha1). The API and implementation are subject to change.
Why does ClickHouse need special treatment?
Every other RDBMS RepoDB targets — SQL Server, PostgreSQL, MySQL, MariaDB, Oracle, DB2, Firebird — is a row-oriented, transactional store. Write a row, it’s there; update a row, it changes in place; declare an identity column, the server hands you back a generated key. RepoDB’s CRUD model was built around that shape.
ClickHouse is a columnar, OLAP-oriented engine, and it doesn’t share that shape:
- There’s no identity/auto-increment mechanism — key generation is the caller’s job.
- There’s no native
UPSERT/MERGEstatement — deduplication is a table-engine concern (ReplacingMergeTreeand friends), not a write-time one. UPDATE/DELETEare background mutations, not synchronous in-place writes.
Rather than force ClickHouse to pretend to be a row-store, RepoDb.ClickHouse exposes RepoDB’s usual CRUD surface but is explicit about where ClickHouse’s semantics show through — covered call by call further down.
Architecture
RepoDb.ClickHouse is a System.Data.Common-based provider extension built directly on top of the official ClickHouse.Driver ADO.NET package — the same pattern RepoDB uses for every other provider:
Application / RepoDB fluent & raw-SQL calls
│
▼
ClickHouseConnection
│
▼
ClickHouse.Driver
│
▼
ClickHouse Server (HTTP/Native protocol)
Bulk Operations sit in their own package, RepoDb.ClickHouse.BulkOperations, and take a different path than the CRUD layer above them — instead of generating INSERT statements, they go straight to ClickHouse’s own binary insert protocol:
BulkInsert / BulkUpdate / BulkMerge / BulkDelete / BulkDeleteByKey
│
▼
ClickHouseBulkCopy
│
├── ColumnFilteredDataReader (projects source data to the mapped columns)
├── ClickHouseBulkCopyColumnMapping
└── ClickHouseBulkInsertMapItem
│
▼
ClickHouseClient.InsertBinaryAsync() (ClickHouse.Driver's native binary protocol)
│
▼
ClickHouse Server
That native binary path is the same category of optimization as SqlBulkCopy on SQL Server or LOAD DATA LOCAL INFILE on MariaDB — except here, unlike Firebird (whose ADO.NET driver has no bulk protocol at all and needed a hand-rolled FirebirdCommandBatcher), ClickHouse.Driver already ships a native bulk-insert method, so ClickHouseBulkCopy is a thin adapter over it rather than a from-scratch implementation.
The objects
| RepoDb.ClickHouse.BulkOperations | Purpose |
|---|---|
ClickHouseBulkCopy | Writes an IDataReader or DataRow[] to a table via the native binary insert protocol |
ColumnFilteredDataReader | Wraps a source reader, projecting only the columns present in ColumnMappings |
ClickHouseBulkCopyColumnMapping | Defines the mapping between a source column and a destination column |
ClickHouseBulkInsertMapItem | A single resolved source-to-destination mapping entry used during the write |
ClickHouseConstants | Shared constants (default batch sizes, timeouts, and the like) |
On the core RepoDb.ClickHouse side, setup runs through ClickHouseGlobalConfiguration/ClickHouseBootstrap, which register the ClickHouse-specific IDbSetting, IDbHelper, and IStatementBuilder implementations RepoDB needs to generate correct SQL for the provider — the same bootstrapping pattern used by every other RepoDB extension.
Basic usage
GlobalConfiguration
.Setup()
.UseClickHouse();
var connectionString =
"Host=127.0.0.1;" +
"Port=8123;" +
"Username=default;" +
"Password=YourPassword;" +
"Database=RepoDb;" +
"Protocol=http;" +
"UseCustomDecimals=false;";
using var connection = new ClickHouseConnection(connectionString);
var people = connection.QueryAll<Person>();
foreach (var person in people)
{
Console.WriteLine($"{person.Id}: {person.Name} ({person.Age})");
}
UseCustomDecimals=false isn’t optional boilerplate — without it, Decimal columns won’t round-trip correctly through ClickHouse.Driver.
Insert — you own the key
ClickHouse has no identity column, so Insert/InsertAll/BulkInsert never generate a key on your behalf:
using (var connection = new ClickHouseConnection(connectionString))
{
var person = new Person { Id = 1, Name = "John Doe", Age = 30 };
connection.Insert(person);
}
If your table doesn’t already have a natural key, generating one (a Guid, a snowflake ID, a counter you own) is on the application side of the line.
Query — the familiar part
using (var connection = new ClickHouseConnection(connectionString))
{
var person = connection.Query<Person>(e => e.Id == 1).FirstOrDefault();
var adults = connection.Query<Person>(e => e.Age >= 18);
}
This part behaves exactly like every other RepoDB provider — the columnar storage underneath doesn’t change the shape of a Query call.
Update and Delete — asynchronous mutations
ClickHouse implements UPDATE/DELETE as background mutations (ALTER TABLE ... UPDATE/DELETE), not synchronous in-place writes. RepoDb.ClickHouse issues the mutation either way, but by default it doesn’t wait for ClickHouse to finish applying it:
using (var connection = new ClickHouseConnection(connectionString))
{
var person = connection.Query<Person>(e => e.Id == 1).FirstOrDefault();
person.Age = 31;
connection.Update(person); // mutation is queued, not necessarily applied yet
connection.Delete<Person>(2); // same here
}
If your workflow needs a call to only return once the mutation has actually taken effect, opt in once at startup:
GlobalConfiguration
.Setup()
.UseClickHouse(isWaitForMutationsEnabled: true);
With that flag on, Update/Delete (and their bulk counterparts) poll ClickHouse until the mutation completes before returning — useful for tests and workflows that need read-your-writes consistency, at the cost of the call taking as long as the mutation itself does.
Merge — an INSERT, not an UPSERT
ClickHouse has no native MERGE/UPSERT statement, so Merge and BulkMerge compile to a plain INSERT by default:
using (var connection = new ClickHouseConnection(connectionString))
{
var person = new Person { Id = 1, Name = "John Doe", Age = 32 };
connection.Merge(person); // an INSERT under the hood
}
Deduplication on ClickHouse is a table-engine decision, not a write-time one — pair this with a ReplacingMergeTree-family engine (and its background merge/OPTIMIZE ... FINAL behavior) if you need “last write wins” semantics on a key.
Bulk operations
For high-throughput loads — the kind of workload ClickHouse exists for — RepoDb.ClickHouse.BulkOperations skips SQL generation entirely and writes straight through ClickHouse.Driver’s native binary insert protocol via ClickHouseBulkCopy:
using (var connection = new ClickHouseConnection(connectionString))
{
var people = GetPeopleToInsert();
var insertedRows = connection.BulkInsert(people);
var peopleToMerge = GetPeopleToMerge();
var mergedRows = connection.BulkMerge(peopleToMerge); // also compiles to an insert
var keysToDelete = new long[] { 1, 2, 3 };
var deletedByKeyRows = connection.BulkDeleteByKey<Person>(keysToDelete);
}
Because it’s a genuinely native protocol rather than batched statement text, this is the path to reach for whenever you’re loading the kind of row counts ClickHouse is meant to hold — not the row-by-row or small-batch InsertAll path.
Where this fits with RepoDB’s broader architecture
In terms of RepoDB’s layered architecture, RepoDb.ClickHouse sits in the same ORM layer as every other provider — CRUD, Batch CRUD, Bulk CRUD, Multiple DB Support — it’s simply the first provider in that layer built for an OLAP-shaped destination rather than a transactional one. Expect any future Cloud Data Warehouse/Lake connectors to lean on the same lessons this preview surfaced: identity, mutation semantics, and merge behavior all need to be provider-aware rather than assumed.
What’s next
The initial development effort prioritizes the core CRUD, raw-SQL, and Bulk Operations surface described above. As a preview, expect the API — particularly around mutation-wait behavior and merge/dedup semantics — to keep moving before a stable release. If you run into a rough edge, or want to help shape how RepoDB handles OLAP-style engines going forward, contributions and feedback are very welcome.
- GitHub: RepoDB
NuGet: RepoDb.ClickHouse RepoDb.ClickHouse.BulkOperations - Announcement: Preview Support for ClickHouse is Now Available in RepoDB
~ Conceptualized by me. Reviewed and checked by AI. Refined and gatekept by me. ~
Please support us
Give a ⭐ to our Github repository | Follow me at X / Twitter | Connect with us at our official Teams Channel
Apache License 2.0 — Copyright © 2026 by Michael Camara Pendon / Create a Request
