Get started Dashboard
Observability ·

ClickHouse native TCP client for .NET: setup and migration

Explore with AI

On 16 October 2026 ClickHouse released an experimental native TCP client for .NET, ClickHouseTcpClient, inside the existing ClickHouse.Driver NuGet package. It speaks the native protocol on port 9000 (9440 with TLS), moves data as columnar blocks, and ClickHouse puts it at 1.5x to 2x faster than the HTTP client for typical work. It needs .NET 8 or later and has no ADO.NET, ORM, file format or token auth support yet, so most teams will run it next to the HTTP client.

On this page

ClickHouse announced a native TCP client for .NET on 16 October 2026. It lives in the same ClickHouse.Driver package you may already use, as a separate class, ClickHouseTcpClient. It speaks the protocol that clickhouse-client uses and sends data in the Native columnar format. ClickHouse says it runs 1.5x to 2x faster than the HTTP client for typical use, and in some cases cuts GC allocations by over 99%.

It is experimental, it needs .NET 8 or later, and ClickHouse says plainly that it is not a drop-in replacement for the HTTP client. The practical approach: keep the HTTP client where you depend on ADO.NET, ORMs, file formats or bearer tokens, and move your heavy reads and bulk inserts to TCP. This guide covers what changed, how to decide, the setup, and the behaviour that will catch you out.

What the ClickHouse native TCP client for .NET is

The ClickHouse.Driver package now holds two clients:

  • HTTP client (ClickHouseClient, plus ADO.NET types such as ClickHouseConnection). It sends RowBinary, a row-oriented format, over HTTP. Until this release it was the only official .NET client.
  • Native TCP client (ClickHouseTcpClient). It uses the native protocol on port 9000, or 9440 with TLS, and moves columnar blocks. It supports .NET 8.0, 9.0 and 10.0.

The TCP API is experimental, so its types raise compiler warning CHTCP0001, and ClickHouse says the public API may change in a future release. On the server side, the docs say the client officially supports the last 2 LTS versions plus the last 3 releases of ClickHouse.

Why native blocks beat RowBinary over HTTP

ClickHouse stores data in columns. With RowBinary over HTTP, every result got converted twice. The server turned its column blocks into rows, then the client decoded those rows one value at a time. Inserts did the same two steps backwards.

The native protocol skips both conversions. A block holds every value of the first column, then every value of the next one, and each column starts with its name and type. For fixed-width types such as Int64 or Float64, the wire layout matches .NET’s memory layout. That means the client reads a whole column in one bulk call into a pooled buffer and hands it to you as a ReadOnlySpan<T>, with no decoding at all.

ClickHouse benchmarked the two clients with the client on an AWS m7i.8xlarge and the server on ClickHouse Cloud (26.4), over a real network. Reads covered 1M rows and inserts 100k rows of the hits dataset, which has 105 scalar columns. On a 32-vCPU client, the best warm run of one streamed read of hits reached 1.1M rows/s with TCP columns, 764k rows/s with TCP POCOs and 486k rows/s over HTTP. The chart shows two more of the published results:

16 parallel hits inserts

thousand rows/s
HTTP client 405
TCP client, columns 877

SELECT 1, 16 in flight

queries/s
HTTP client 1,800
TCP client, columns 3,100
Figure 1
HTTP client against TCP columns, best warm run, from ClickHouse's announcement

Memory shows the biggest gap. Reading 1M rows of hits allocates 1.6 GB with HTTP and 6 MB with TCP columns. Using median timings, column reads took 0.44x the HTTP time on hits, and POCO reads took about two thirds. JSON was the exception: it ran slower over TCP, because JSON columns travel as strings.

The three tiers decide how much speed you keep

The client gives you three ways to read and write, and the speedup depends on which one you use:

  • Columns: read with StreamAsync (borrowed blocks), write with InsertAsync. No per-row allocation. This is the fast path.
  • POCOs: read with QueryAsync<T>, write with InsertRowsAsync<T>. One object per row. The mapping is compiled per column on first use, with no boxing.
  • object arrays: read with QueryAsync, write with InsertRowsAsync. One array per row and a box per value, and inserts have to turn rows back into columns. ClickHouse says this tier loses most of the gain, and ORMs, which usually work this way, would see little improvement.

Deciding whether TCP fits your workload

ClickHouse’s advice is short. Analytical reads, bulk inserts and other performance-sensitive work should try TCP. ORMs, other file formats, token auth and heavy JSON use should stay on HTTP. Both clients ship in one package, so one app can use both.

Decision path for picking the ClickHouse HTTP client or the new native TCP client in a .NET app

graph TB A[New or existing .NET code on ClickHouse] --> B{Needs Dapper, EF Core or ADO.NET?} B -->|yes| H[Stay on the HTTP client] B -->|no| C{Needs CSV, Parquet or bearer auth?} C -->|yes| H C -->|no| D{Heavy use of JSON columns?} D -->|yes| H D -->|no| E{Runs on .NET 8 or later?} E -->|no| H E -->|yes| F[Try ClickHouseTcpClient on port 9000] F --> G[Move hot loops to StreamAsync] style H fill:#fee2e2,stroke:#fca5a5,color:#7f1d1d style F fill:#d1fae5,stroke:#6ee7b7,color:#065f46 style G fill:#d1fae5,stroke:#6ee7b7,color:#065f46

The features that stay HTTP-only, according to the docs and the announcement:

  • ADO.NET and ORMs (Dapper, EF Core, linq2db)
  • CSV, JSONEachRow, Parquet and raw stream input and output
  • JWT or bearer authentication and custom headers
  • Typeless @name parameters, custom parameter resolvers, formatters and read converters
  • A per-query database or role
  • Structured JSON results (HTTP gives you a JsonObject or a POCO)

What you only get on TCP: columnar block reads, progress, profile and server log callbacks, cancellation that sends a Cancel packet to the server, sessions pinned to one socket, and W3C trace context propagation to ClickHouse.

Getting a first query through on port 9000

  1. Install the package. The TCP client ships in the regular package:
dotnet add package ClickHouse.Driver
  1. Acknowledge the experimental warning. Add CHTCP0001 to NoWarn in your project file. The docs say to do this only once you accept that code using the API may need changes later.
<PropertyGroup>
  <NoWarn>$(NoWarn);CHTCP0001</NoWarn>
</PropertyGroup>
  1. Create one client and reuse it. The client is thread-safe and owns a connection pool. Create one per endpoint, share it, and dispose it asynchronously at shutdown.
using ClickHouse.Driver.Tcp;

await using var client = new ClickHouseTcpClient(
    "Host=my.clickhouse;Port=9000;Username=user");

object version = await client.ExecuteScalarAsync("SELECT version()");
Console.WriteLine(version);
  1. Turn on TLS for anything off your laptop. Without TLS, the password and all query data cross the network unencrypted. With UseTls on and no port set, the client uses 9440. The secure native port is a separate endpoint, and a TLS client never falls back to plaintext.
var builder = new ClickHouseTcpConnectionStringBuilder
{
    Host = "my.clickhouse",
    Username = "user",
    UseTls = true,
    Compression = "zstd",
};

await using var client = new ClickHouseTcpClient(builder.ToOptions());

Compression defaults to LZ4. Connection strings accept lz4, zstd or none.

  1. Check the handshake and readiness. GetServerInfoAsync returns the server version, timezone and negotiated protocol revision. PingAsync throws on failure, where the HTTP client returns false.
ClickHouseTcpServerInfo server = await client.GetServerInfoAsync();
Console.WriteLine($"ClickHouse {server.Version}, revision {server.ProtocolRevision}, tz {server.Timezone}");

await client.PingAsync(cancellationToken);

A successful ping proves native connectivity and authentication. It does not prove the user can read your tables.

  1. Register it once with dependency injection. AddClickHouseTcpDataSource registers a singleton and hands out IClickHouseTcpClient. The container owns the pool, so never dispose the injected client.
services.AddClickHouseTcpDataSource(
    "Host=my.clickhouse;UseTls=true;Username=user");

Server settings go in the connection string with a set_ prefix, for example set_max_threads=4. In code, put them in CustomSettings without the prefix.

Reading results as rows, POCOs or borrowed blocks

Start with POCOs, since they sit closest to what HTTP code already does. Columns map to public settable properties, matched without regard to case or underscores. ClickHouseTcpColumn sets an explicit name and ClickHouseTcpNotMapped excludes a property. You don’t need a registration call.

public sealed class Event
{
    public ulong Id { get; set; }

    [ClickHouseTcpColumn(Name = "event_name")]
    public string Name { get; set; } = string.Empty;

    public DateTime RecordedAt { get; set; }
}

await foreach (Event row in client.QueryAsync<Event>(
    "SELECT id, event_name, recorded_at FROM events ORDER BY id"))
{
    Console.WriteLine($"{row.Id}: {row.Name} at {row.RecordedAt:O}");
}

Once that works, move the loops that matter to StreamAsync. Each block exposes its columns as spans over the buffer the socket read into:

ulong[] retained = Array.Empty<ulong>();

await foreach (Block block in client.StreamAsync(
    "SELECT id, score FROM events ORDER BY id"))
{
    ReadOnlySpan<ulong> ids = block.Column<ulong>("id").Values;
    ReadOnlySpan<double> scores = block.Column<double>("score").Values;

    for (int row = 0; row < block.RowCount; row++)
    {
        // process in place
    }

    retained = ids.ToArray(); // copy anything that must outlive this iteration
}

A block, its columns and their spans are valid for one iteration only. After that the buffers go back to the pool. If you hold a column object and read it after the loop moves on, the client throws. It won’t quietly hand you another block’s data.

Inserting in columns, and making retries safe

All inserts use an INSERT INTO ... VALUES statement with no inline values. InsertAsync takes typed columns, matched by name, and is the fastest path:

var columns = new IColumn[]
{
    ClickHouseTcpColumn.Create("id", new ulong[] { 1, 2, 3 }),
    ClickHouseTcpColumn.Create("name", new[] { "Ada", "Grace", "Alan" }),
    ClickHouseTcpColumn.Create("score", new[] { 99.5, 97.25, 91.0 }),
};

await client.InsertAsync(
    "INSERT INTO events (id, name, score) VALUES",
    columns,
    new ClickHouseTcpInsertOptions
    {
        MaxRowsPerBlock = 10_000,
        DeduplicationToken = batchContentHash,
    });

Three settings matter here:

  • MaxRowsPerBlock defaults to 50,000. Raising it can help narrow rows but raises peak memory. null sends the whole insert as one block.
  • MaxSendBufferBytes defaults to 1 MiB. It is a soft cap checked between columns, so one wide column can go past it.
  • DeduplicationToken marks one batch for server-side deduplication. The TCP client has no automatic retries, and a cancelled or timed-out insert can end with an unknown outcome. Derive the token from the batch contents and reuse it on every retry of that batch.

Row and POCO inserts are converted one block at a time. A bad value in a later block can surface after earlier blocks have already gone out, so validate types and nullability first when the data is messy.

Moving an existing HTTP app over, one call at a time

The TCP API is separate from IClickHouseClient. You can’t switch transports with a flag. Most SQL carries over, and each call has its own counterpart:

  • ExecuteNonQueryAsync becomes ExecuteAsync, which returns no affected-row count.
  • ExecuteScalarAsync stays ExecuteScalarAsync, but the TCP version drains the whole response before it returns.
  • ExecuteReaderAsync becomes QueryAsync, QueryAsync<T> or StreamAsync. TCP has no DbDataReader.
  • InsertBinaryAsync becomes InsertRowsAsync<T>, InsertRowsAsync or InsertAsync, none of which return a row count.
  • ExecuteRawResultAsync, InsertRawStreamAsync and CreateConnection() have no TCP equivalent. Keep HTTP for those.

Parameters change shape. @name placeholders become ClickHouse-native {name:Type} placeholders, and the values move into ClickHouseTcpQueryOptions.Parameters. For instants, put the timezone in the type:

var options = new ClickHouseTcpQueryOptions
{
    QueryId = "events-by-score",
    Settings = new Dictionary<string, string> { ["max_execution_time"] = "30" },
    Parameters = new ClickHouseTcpParameterCollection { { "minimum", 18.0 } },
};

await foreach (object[] row in client.QueryAsync(
    "SELECT id, score FROM events WHERE score >= {minimum:Float64}",
    options))
{
    Console.WriteLine($"{row[0]}: {row[1]}");
}

ClickHouse’s Tcp_004_MigratingFromHttp.cs example runs both clients against the same table, which is a useful template for a side-by-side check before you cut over.

Watching queries through callbacks, query IDs and trace context

The native protocol carries more than rows. While a query runs, the server sends progress, profile events, totals, extremes and, on request, its own log messages as separate packets. Set Callbacks on the query options to receive them:

ClickHouseTcpProgress total = default;

var options = new ClickHouseTcpQueryOptions
{
    QueryId = $"report-{Guid.NewGuid():N}",
    Callbacks = new ClickHouseTcpQueryCallbacks
    {
        OnProgress = progress => total += progress,
        OnProfileInfo = profile =>
            Console.WriteLine($"{profile.Rows} rows in {profile.Blocks} blocks"),
    },
};

Progress values are increments, so add them up yourself. Callbacks run synchronously on the thread reading the response, so keep them fast and never throw from them. Inserts produce no progress packets. OnBlockWritten reports blocks the client sent, which tells you nothing about whether the server committed them.

For tracing, QueryId shows up in system.query_log, client logs and trace spans. Pass an ID from your own request and you can join a slow ClickHouse query back to the request that ran it. When I built observability for Workers at Cloudflare, joining spans across a boundary like this was what made a slow request explainable. The client also propagates W3C trace context to ClickHouse. LoggerFactory writes lifecycle events under ClickHouse.Driver.Tcp.* categories, and the ClickHouse.Driver.Tcp.Pool category is the one to watch for pool exhaustion and frequent redials. Statement text stays out of OpenTelemetry tags unless you set IncludeSqlInActivityTags, and StatementMaxLength defaults to 5 characters, because SQL can hold sensitive data.

Where adopting the TCP client goes wrong

  • Keeping data from a borrowed block. Spans die at the end of the iteration. Copy with ToArray() inside the loop.
  • Breaking out of a result early. Breaking early or cancelling closes that connection. The pool can’t reuse it. Do this in a hot path and you redial constantly. Use LIMIT and select only the columns you need. Always finish or dispose each enumeration.
  • Running into the pool cap. MaxPoolSize defaults to 20 and caps open connections and concurrent operations together. Extra callers wait up to PoolTimeout (30 seconds by default). An open stream holds a connection, and so does every session for as long as it lives. Raising the cap does not make one query faster.
  • Treating ReadTimeout as a query timeout. ReadTimeout (5 minutes by default) measures one stretch of server silence and restarts with every read. For an end-to-end limit, pair a CancellationToken with the server’s max_execution_time.
  • Expecting DateTime values on the column tier. Columns and object arrays return raw uint or long counts. Use Block.ReadAs<DateTimeOffset> or a typed POCO property. DateTimeOffset has 100-nanosecond resolution, so DateTime64 at scale 8 or 9 loses precision through it.
  • Temporary tables and SET on pooled calls. Ordinary calls can land on any pooled connection, possibly on a different server. Use OpenSessionAsync. A session runs one operation at a time, and cancellation can end it and take its temporary tables with it.
  • JSON on a read-only user. JSON comes back as text, and ClickHouse may reorder keys or reformat numbers. The client sends serialisation settings by default. A read-only user that can’t change settings needs SendJsonAndDynamicSerializationSettings=false, and reading those types may then fail.
  • Scalar queries over big results. ExecuteScalarAsync reads the whole response before returning. Write the query so it returns one row.
  • Types with no mapping. AggregateFunction columns are unsupported, so finalise them on the server with a function like sumMerge. Strided three-argument QBit is unsupported too. Under NativeAOT, POCO mapping reads one value at a time.
  • Skipping certificate checks. Leave TlsAllowInvalidCertificates off outside local development, and review any ConfigureTls callback, because it runs last and can override validation.

What ClickHouse plans for the client next

The announcement names two next steps. First comes ADO.NET: a DbConnection over the native protocol, so Dapper, EF Core and linq2db can use it. After that comes binary JSON, to remove the string round trip. Until both land, the split in the diagram above holds. ClickHouse asks for feedback through GitHub issues on the clickhouse-cs repository while the API is still experimental. ClickHouse also publishes 30 runnable examples there, covering reads, inserts, sessions, TLS, observability and error handling.

See Polylane for impact analysis.

Common questions.

Which NuGet package contains the ClickHouse native TCP client for .NET?

The regular ClickHouse.Driver package. Install it with dotnet add package ClickHouse.Driver and use the ClickHouseTcpClient class from the ClickHouse.Driver.Tcp namespace. The HTTP client ships in the same package.

Which port does the .NET TCP client connect to?

Port 9000 without TLS and 9440 with UseTls=true, which is the default when you leave Port unset. The secure native protocol is a separate server endpoint. A TLS client must reach the secure native port and never falls back to plaintext.

How do I silence warning CHTCP0001?

Add CHTCP0001 to NoWarn in a PropertyGroup in your project file. The warning exists because the TCP API is experimental and may change in a future release, so suppress it only once you accept that.

Can I use Dapper or Entity Framework Core with the TCP client?

Not yet. The TCP client has no ADO.NET provider, so Dapper and EF Core must keep using the HTTP client. ClickHouse lists an ADO.NET DbConnection over the native protocol as its first planned addition.

How much faster is the TCP client than the HTTP client?

ClickHouse reports 1.5x to 2x for typical use. In its benchmark, one streamed read of the hits dataset reached 1.1M rows/s with TCP columns against 486k rows/s over HTTP, and reading 1M rows allocated 6 MB against 1.6 GB. JSON columns are slower over TCP because they travel as text.

Does the TCP client retry failed inserts?

No. There are no automatic retries, and a cancelled or timed-out insert can have an unknown outcome. Set a DeduplicationToken derived from the batch contents in ClickHouseTcpInsertOptions and reuse it on each retry of that batch.

Which .NET and ClickHouse versions does it support?

It supports .NET 8.0, 9.0 and 10.0. On the server side, it officially supports the last 2 LTS versions and the last 3 releases of ClickHouse.

Why do my temporary tables disappear between calls?

Ordinary calls borrow any pooled connection, and different pooled connections can reach different servers. Open a session with OpenSessionAsync so temporary tables and SET statements stay on one socket. Cancelling inside a session can end it and lose that state.

Sources (2)

  1. Introducing the (blazing fast!) native TCP client for .NET (ClickHouse blog)
  2. ClickHouse C# native TCP client (ClickHouse docs)

About the author

Boris Tane

Founder of Polylane

Boris Tane is the founder of Polylane. He previously founded Baselime, observability for the future of the cloud, which Cloudflare acquired. At Cloudflare he built and led the Workers observability team.

Related

Nobody should be on-call. Polylane watches your infra, finds what broke, and writes the fix.

Get started for free