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
- What the ClickHouse native TCP client for .NET is
- Why native blocks beat RowBinary over HTTP
- Deciding whether TCP fits your workload
- Getting a first query through on port 9000
- Reading results as rows, POCOs or borrowed blocks
- Inserting in columns, and making retries safe
- Moving an existing HTTP app over, one call at a time
- Watching queries through callbacks, query IDs and trace context
- Where adopting the TCP client goes wrong
- What ClickHouse plans for the client next
- Common questions
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 asClickHouseConnection). 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
SELECT 1, 16 in flight
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 withInsertAsync. No per-row allocation. This is the fast path. - POCOs: read with
QueryAsync<T>, write withInsertRowsAsync<T>. One object per row. The mapping is compiled per column on first use, with no boxing. - object arrays: read with
QueryAsync, write withInsertRowsAsync. 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.
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
@nameparameters, custom parameter resolvers, formatters and read converters - A per-query database or role
- Structured JSON results (HTTP gives you a
JsonObjector 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
- Install the package. The TCP client ships in the regular package:
dotnet add package ClickHouse.Driver
- Acknowledge the experimental warning. Add
CHTCP0001toNoWarnin 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>
- 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);
- Turn on TLS for anything off your laptop. Without TLS, the password and all query data cross the network unencrypted. With
UseTlson 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.
- Check the handshake and readiness.
GetServerInfoAsyncreturns the server version, timezone and negotiated protocol revision.PingAsyncthrows on failure, where the HTTP client returnsfalse.
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.
- Register it once with dependency injection.
AddClickHouseTcpDataSourceregisters a singleton and hands outIClickHouseTcpClient. 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:
MaxRowsPerBlockdefaults to 50,000. Raising it can help narrow rows but raises peak memory.nullsends the whole insert as one block.MaxSendBufferBytesdefaults to 1 MiB. It is a soft cap checked between columns, so one wide column can go past it.DeduplicationTokenmarks 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:
ExecuteNonQueryAsyncbecomesExecuteAsync, which returns no affected-row count.ExecuteScalarAsyncstaysExecuteScalarAsync, but the TCP version drains the whole response before it returns.ExecuteReaderAsyncbecomesQueryAsync,QueryAsync<T>orStreamAsync. TCP has noDbDataReader.InsertBinaryAsyncbecomesInsertRowsAsync<T>,InsertRowsAsyncorInsertAsync, none of which return a row count.ExecuteRawResultAsync,InsertRawStreamAsyncandCreateConnection()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
LIMITand select only the columns you need. Always finish or dispose each enumeration. - Running into the pool cap.
MaxPoolSizedefaults to 20 and caps open connections and concurrent operations together. Extra callers wait up toPoolTimeout(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 aCancellationTokenwith the server’smax_execution_time. - Expecting DateTime values on the column tier. Columns and object arrays return raw
uintorlongcounts. UseBlock.ReadAs<DateTimeOffset>or a typed POCO property.DateTimeOffsethas 100-nanosecond resolution, soDateTime64at 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.
ExecuteScalarAsyncreads the whole response before returning. Write the query so it returns one row. - Types with no mapping.
AggregateFunctioncolumns are unsupported, so finalise them on the server with a function likesumMerge. Strided three-argumentQBitis unsupported too. Under NativeAOT, POCO mapping reads one value at a time. - Skipping certificate checks. Leave
TlsAllowInvalidCertificatesoff outside local development, and review anyConfigureTlscallback, 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.
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)
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
- ClickHouse Code 117 “Expected end of line”: causes and fixes ClickHouse Code 117 Expected end of line means the CSV parser found extra text after a row's last column. Find the bad row, then fix quotes, columns or types.
- Profiling Cloudflare Workers CPU and memory with flamegraphs Capture on-demand CPU and heap profiles of deployed Cloudflare Workers and Durable Objects, read the flamegraph, and avoid the limits that trip teams up.
- Vercel AI Gateway session affinity logs: reading the Sessions view Vercel AI Gateway logs got a Sessions view on 17 October 2026. Learn how session affinity groups requests, how to send a session ID and how to read cache hits.
- How to Monitor Vercel Cron Jobs: Heartbeats, Logs and Alerts Monitor Vercel cron jobs: why failed runs aren't retried or always logged, how to add a success-only heartbeat, read cron logs and alert on missed runs.
- Fix Cloudflare “Error 500: internal server error” Why Cloudflare shows Error 500: internal server error, how to tell if your origin, a Worker or a Page Rule caused it, and the fix for each cause.