Introducing RepoDb.ClickHouse — RepoDB’s Provider for ClickHouse Analytics Workloads

7 minute read

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/MERGE statement — deduplication is a table-engine concern (ReplacingMergeTree and friends), not a write-time one.
  • UPDATE/DELETE are 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.BulkOperationsPurpose
ClickHouseBulkCopyWrites an IDataReader or DataRow[] to a table via the native binary insert protocol
ColumnFilteredDataReaderWraps a source reader, projecting only the columns present in ColumnMappings
ClickHouseBulkCopyColumnMappingDefines the mapping between a source column and a destination column
ClickHouseBulkInsertMapItemA single resolved source-to-destination mapping entry used during the write
ClickHouseConstantsShared 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.


~ 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