# ClickHouse native TCP client for .NET: setup and migration

> ClickHouse's new native TCP client for .NET, released 16 October 2026: what it is, when to pick it over HTTP, setup on port 9000, and the gotchas to plan for.

By Boris Tane, Founder of Polylane · Published October 19, 2026 · 12 min read
Canonical: https://polylane.com/learn/observability/clickhouse-native-tcp-client-for-net/

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.

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:

**Figure 1. HTTP client against TCP columns, best warm run, from ClickHouse's announcement**

| Metric | HTTP client | TCP client, columns |
| --- | ---: | ---: |
| 16 parallel hits inserts (thousand rows/s) | 405 | 877 |
| SELECT 1, 16 in flight (queries/s) | 1,800 | 3,100 |

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.

```mermaid alt="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:

```bash
dotnet add package ClickHouse.Driver
```

2. **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.

```xml
<PropertyGroup>
  <NoWarn>$(NoWarn);CHTCP0001</NoWarn>
</PropertyGroup>
```

3. **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.

```csharp
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);
```

4. **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.

```csharp
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`.

5. **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`.

```csharp
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.

6. **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.

```csharp
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.

```csharp
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:

```csharp
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:

```csharp
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:

```csharp
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:

```csharp
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](https://polylane.com/use-cases/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

- [Introducing the (blazing fast!) native TCP client for .NET (ClickHouse blog)](https://clickhouse.com/blog/introducing-the-blazing-fast-native-tcp-client-for-net)
- [ClickHouse C# native TCP client (ClickHouse docs)](https://clickhouse.com/docs/integrations/language-clients/csharp/tcp)

## About the author

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](https://polylane.com/learn/troubleshooting/clickhouse-code-117-db-exception-expected-end-of-line/): 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](https://polylane.com/learn/observability/cloudflare-workers-cpu-memory-profiling-flamegraph/): 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](https://polylane.com/learn/observability/vercel-ai-gateway-session-affinity-logs/): 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](https://polylane.com/learn/monitoring-coverage/how-to-monitor-vercel-cron-jobs/): 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”](https://polylane.com/learn/troubleshooting/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.

Get started with one command: `curl -fsSL https://polylane.com/setup | bash` installs the CLI, connects your coding agents, and creates the account.
