Skip to main content
The official C# client for connecting to ClickHouse. The client source code is available in the GitHub repository. Originally developed by Oleg V. Kozlyuk. The library provides two main APIs:
  • ClickHouseClient (recommended): A high-level, thread-safe client designed for singleton use. Provides a simple async API for queries and bulk inserts. Best for most applications.
  • ADO.NET (ClickHouseDataSource, ClickHouseConnection, ClickHouseCommand): Standard .NET database abstractions. Required for ORM integration (Dapper, Linq2db) and when you need ADO.NET compatibility. ClickHouseBulkCopy is a helper class for efficiently inserting data using an ADO.NET connection. ClickHouseBulkCopy is deprecated and will be removed in a future release; use ClickHouseClient.InsertBinaryAsync instead.
Both APIs share the same underlying HTTP connection pool and can be used together in the same application.

Migration guide

  1. Update your .csproj file with the new package name ClickHouse.Driver and the latest version on NuGet.
  2. Update all ClickHouse.Client references to ClickHouse.Driver in your codebase.

Supported .NET versions

ClickHouse.Driver supports the following .NET versions:
  • .NET 6.0
  • .NET 8.0
  • .NET 9.0
  • .NET 10.0

Supported ClickHouse versions

The client officially supports the last 3 releases plus the last two LTS releases.

Installation

Install the package from NuGet:
Or using the NuGet Package Manager:

Quick start

Configuration

There are two ways of configuring your connection to ClickHouse:
  • Connection string: Semicolon-separated key/value pairs that specify the host, authentication credentials, and other connection options.
  • ClickHouseClientSettings object: A strongly typed configuration object that can be loaded from configuration files or set in code.
Below is a full list of all the settings, their default values, and their effects.

Connection settings

Data format & serialization

Session management

The UseSession flag enables persistence of the server session, allowing use of SET statements and temporary tables. Sessions will be reset after 60 seconds of inactivity (default timeout). Session lifetime can be extended by setting session settings via ClickHouse statements or the server configuration.The ClickHouseConnection class normally allows for parallel operation (multiple threads can run queries concurrently). However, enabling UseSession flag will limit that to one active query per connection at any moment of time (this is a server-side limitation).

Security

HTTP client configuration

Logging & debugging

Custom settings & roles

When using a connection string to set custom settings, use the set_ prefix, e.g. “set_max_threads=4”. When using a ClickHouseClientSettings object, don’t use the set_ prefix.For a full list of available settings, see here.

Connection string examples

Basic connection

With custom ClickHouse settings


QueryOptions

QueryOptions allows you to override client-level settings on a per-query basis. All properties are optional and only override the client defaults when specified. Example:

InsertOptions

InsertOptions extends QueryOptions with settings specific to bulk insert operations via InsertBinaryAsync. All QueryOptions properties are also available on InsertOptions. Example:

Skipping the schema probe query

By default, InsertBinaryAsync sends a SELECT ... WHERE 1=0 query before each insert to discover column types. For high-throughput scenarios, you can eliminate this overhead with two options: Option 1: Provide column types explicitly When you know the table schema at compile time, pass it directly via ColumnTypes. No schema query is sent at all:
Option 2: Cache the schema When you insert into the same table repeatedly, set UseSchemaCache = true to query the schema once and reuse it for subsequent inserts on the same ClickHouseClient instance:
  • ColumnTypes takes priority over UseSchemaCache. If both are set, the explicit types are used.
  • The schema cache does not detect ALTER TABLE changes. If you modify the table schema, create a new ClickHouseClient or avoid UseSchemaCache for that table.
  • The cache is scoped to the ClickHouseClient instance and keyed by (database, table). Different column subsets on the same table share a single cached schema.

ClickHouseClient

ClickHouseClient is the recommended API for interacting with ClickHouse. It is thread-safe, designed for singleton use, and manages HTTP connection pooling internally.

Creating a client

Create a ClickHouseClient with a connection string or a ClickHouseClientSettings object. See the Configuration section for available options. The details for your ClickHouse Cloud service are available in the ClickHouse Cloud console. Select a service and click Connect: Choose C#. Connection details are displayed below. If you’re using self-managed ClickHouse, the connection details are set by your ClickHouse administrator. Using a connection string:
Or using ClickHouseClientSettings:
For dependency injection scenarios, use IHttpClientFactory:
ClickHouseClient is designed to be long-lived and shared across your application. Create it once (typically as a singleton) and reuse it for all database operations. The client manages HTTP connection pooling internally.

Executing queries

Use ExecuteNonQueryAsync for statements that don’t return results:
Use ExecuteScalarAsync to retrieve a single value:

Inserting data

Parameterized inserts

Insert data using parameterized queries with ExecuteNonQueryAsync. Parameter types must be specified in the SQL using {name:Type} syntax:

Bulk inserts

Use InsertBinaryAsync for inserting large numbers of rows efficiently. It streams data using ClickHouse’s native row binary format, supports parallel batch uploads, and avoids “URL too long” errors that can occur with parameterized queries.
For large datasets, configure batching and parallelism with InsertOptions:
  • The client automatically fetches table structure via SELECT * FROM <table> WHERE 1=0 before inserting. Provided values must match the target column types. To skip this query, use InsertOptions.ColumnTypes or InsertOptions.UseSchemaCache.
  • When MaxDegreeOfParallelism > 1, batches are uploaded in parallel. Sessions are not compatible with parallel insertion; either disable sessions or set MaxDegreeOfParallelism = 1.
  • Use RowBinaryFormat.RowBinaryWithDefaults in InsertOptions.Format if you want the server to apply DEFAULT values for columns not provided.

POCO inserts

Instead of constructing object[] arrays, you can insert strongly-typed POCO objects directly. Register the type once, then pass IEnumerable<T>:
By default, all public readable properties are mapped to columns using strict case-sensitive name matching. You can customize the mapping with attributes:
When all mapped properties specify an explicit Type, the schema probe query is skipped entirely. When only some properties have explicit types, the driver falls back to the schema probe for the full column set. InsertBinaryAsync<T> supports the same InsertOptions (batching, parallelism, schema caching) as the object[] overload.
Unlike the object[] overload, InsertBinaryAsync<T> does not accept an explicit column list. Columns are determined by the registered type’s mapped properties. To control which columns are inserted, use [ClickHouseNotMapped] to exclude properties or [ClickHouseColumn(Name = "...")] to rename them.If ColumnTypes is set in InsertOptions, they will override the POCO attributes.

Schema evolution

POCO inserts work seamlessly when columns are added to the target table after the type is registered. Because the driver only inserts the columns mapped by the POCO, any new columns with DEFAULT (or other default expressions) are filled in by the server automatically. No code changes or re-registration are needed.

Insert query placement

A binary insert writes its INSERT INTO ... FORMAT ... statement as the first line of the request body, ahead of the rows. The body is compressed by default, so routing and logging that inspect only the URL do not see the statement. Set InsertOptions.QueryPlacement to InsertQueryPlacement.Url to send the statement as the query URL parameter instead, leaving the body to the rows alone:
Use it when a proxy, load balancer or gateway routes or inspects on the query parameter, or when you want the statement in access logs and observability tooling. It is opt-in because the statement then counts towards the URL length. The effective limit is the lowest imposed by the .NET runtime, an intermediary and the server. On .NET 6 through .NET 9, System.Uri limits the complete encoded request URI to 65,519 characters; the driver throws an InvalidOperationException that directs you back to InsertQueryPlacement.Body when this limit is exceeded. ClickHouse’s http_max_uri_size is 1 MiB by default, while an intermediary may impose a lower limit. In body mode, the statement and rows have no such URL-length limit; other request options can still appear in the URL. The setting is independent of Compressor: the body is encoded the same way in both modes.

Reading data

Use ExecuteReaderAsync to execute SELECT queries. The returned ClickHouseDataReader provides typed access to result columns via methods like GetInt64(), GetString(), and GetFieldValue<T>(). Call Read() to advance to the next row. It returns false when there are no more rows. Access columns by index (0-based) or by column name.

POCO reads

Instead of reading columns by index or name, you can stream query results directly into your own classes. Register the type once with the client, then use QueryAsync<T>:
RegisterPocoType<T>() sets up both the insert and read mappings and validates both up front. RegisterBinaryInsertType<T>() is unchanged and remains insert-only for backwards compatibility. A registered type must have:
  • A public parameterless constructor.
  • At least one public property with a public, non-init setter. required properties are supported.
Column matching is case-sensitive. Missing result columns leave properties at their default value; extra result columns are ignored. The driver does not widen or narrow values. Apart from the alternate representations listed below, the column’s framework type must be assignable to the property type, and a mismatch throws InvalidOperationException. An object property therefore accepts any column. QueryAsync<T> reads each of these columns straight into a matching property: Every row also accepts the nullable form of its property type (long?, DateOnly?, and so on), whether or not the column is Nullable(...). A non-nullable value-type property on a Nullable(T) column is accepted at registration, but throws when a NULL arrives. Wrappers like LowCardinality(T), SimpleAggregateFunction(f, T) and Object(T) map exactly as T. Composite columns are supported too, and take the framework type given in the reading type reference: Array(T) into T[], Tuple(...) into System.Tuple<...>, Nested(...) into Tuple<...>[], JSON into JsonObject (or string under JsonReadMode=String), and Variant/Dynamic into object. A Map(K, V) column is a special case: a List<KeyValuePair<K, V>> or KeyValuePair<K, V>[] property reads on the box-free path and keeps the on-wire order and any repeated keys, in either MapReadMode. A Dictionary<K, V> property works in the default mode only. The key and value types must match exactly, so Map(String, Nullable(Int32)) needs KeyValuePair<string, int?>. Where a column offers more than one property type (a DateTime column as DateTime, DateTimeOffset or DateOnly, a String column as string or byte[]) the declared property type selects the representation. These alternate representations belong to the POCO path, so QueryAsync<T> has them and MapTo<T> does not. When iterating a reader manually, use ClickHouseDataReader.MapTo<T>() to materialize the current row into a registered POCO without advancing the reader:
Use MapTo<T> when you must drive the reader loop yourself — for example to mix raw column access with POCO materialization. It reads the row through the reader’s boxed values, so it does not offer the alternate property types above, and it allocates more than QueryAsync<T> does. Prefer QueryAsync<T> when you only need the rows; see choose the materialization path for the numbers. A client-level or per-query read value converter applies to both paths and does not disable the box-free read. The driver converts each column on the overload that matches how it read the column: the typed ConvertValue<T> for a box-free column, and the boxed ConvertValue for a composite column. Implement the two overloads consistently, or the same column gives different results on different paths. When a LoggerFactory is configured, RegisterPocoType<T>() and RegisterBinaryInsertType<T>() emit a Debug-level log (category ClickHouse.Driver.Client) listing which properties mapped to which columns, and which were skipped and why. See Logging and diagnostics.

SQL parameters

In ClickHouse, the standard format for query parameters in SQL queries is {parameter_name:DataType}. Examples:
SQL ‘bind’ parameters are passed as HTTP URI query parameters, so using too many of them may result in a “URL too long” exception. Use InsertBinaryAsync for bulk data insertion to avoid this limitation.

ADO-style @name placeholders

The driver also accepts @name placeholders, which ORMs such as Dapper emit. These are a client-side convenience: before the request is sent, each one is rewritten to {name:ResolvedType}, so the server never sees an @. See type resolution for how the type is chosen. Use the explicit {name:Type} form where you can. A @name with no matching parameter is left untouched for the server to reject. Matching is case-sensitive, so @ID does not bind a parameter named id.
To turn the rewrite off, set the ClickHouse.Driver.DisableReplacingParameters AppContext switch before the first use of the driver. Only the text rewrite stops; parameters are still sent, so queries written with native {name:Type} syntax keep working.

Identifier parameters

The Identifier parameter type lets you safely bind a database, table, or column name instead of a quoted string literal. Use it via the {name:Identifier} syntax in SQL, or by setting ClickHouseDbParameter.ClickHouseType = "Identifier":
The value is sent verbatim, and the server substitutes it as a bare SQL identifier, applying its own backtick quoting and escaping. Identifiers containing special characters (including backticks) round-trip safely.

Query ID

Every query is assigned a unique query_id that can be used to fetch data from the system.query_log table or cancel long-running queries. You can specify a custom query ID via QueryOptions:
If you’re specifying a custom QueryId, ensure it is unique for every call. A random GUID is a good choice.

Custom parameter type mapping

When using @-style parameters (e.g., WHERE id = @id), the driver automatically infers the ClickHouse type from the .NET value type. For example, int maps to Int32.
Behavior for inferred DateTime parametersFor @-style parameters with no {name:Type} hint in the SQL and no ClickHouseType set, instant-bearing values are inferred as DateTime('UTC') rather than a bare DateTime. DateTime with Kind of Utc or Local, and all DateTimeOffset values, are sent as DateTime('UTC'), preserving the instant across any server timezone.Explicit hints ({name:DateTime}) take precedence over inference and are the recommended way of building queries.
To override these defaults, set ParameterTypeResolver on ClickHouseClientSettings. This is useful when you want all DateTime parameters to use DateTime64(3) for millisecond precision, or all decimals to use a specific scale, without setting ClickHouseType on every individual parameter. Using DictionaryParameterTypeResolver for simple type mappings:
Custom IParameterTypeResolver for advanced scenarios: For value-aware or name-based resolution, implement the IParameterTypeResolver interface directly. Return null to fall through to the default inference:
You can also set a resolver for a single query via QueryOptions.ParameterTypeResolver. When set, it takes precedence over the client-level resolver. Type resolution precedence: The resolver is one step in a precedence chain. From highest to lowest priority:
  1. Explicit ClickHouseType set on the parameter
  2. SQL type hint from {name:Type} syntax in the query
  3. IParameterTypeResolver (from QueryOptions.ParameterTypeResolver, falling back to ClickHouseClientSettings.ParameterTypeResolver)
  4. Built-in type inference (TypeConverter.ToClickHouseType)
The resolver also works with the ADO.NET ClickHouseConnection path — the settings are inherited by connections created from the client.

Custom parameter value formatting

IParameterFormatter is a hook that decides how parameter values are serialized. Use it when the built-in formatting (e.g. DateTime precision, decimal culture, string escaping, number representation) does not match what your schema or downstream tools expect. Set ParameterFormatter on ClickHouseClientSettings to install a formatter for all parameterized queries. The formatter receives the value, the resolved ClickHouse type name, and the parameter name, and returns the string representation that is sent to the server. Return null to fall through to the default formatter. Using DictionaryParameterFormatter for simple per-CLR-type formatting:
Custom IParameterFormatter for advanced scenarios:
You can also set a formatter per-query via QueryOptions.ParameterFormatter. When set, it takes precedence over the client-level formatter. Composite values: The formatter runs both for top-level collection parameters and every element inside composite values (Array, Tuple, Map, Nullable, LowCardinality, Variant). For example, a typeof(int) mapping formats every Int32 element of an Array(Int32) individually. Single-quote wrapping in composite contexts: For string-like ClickHouse types (String, FixedString, Enum8, Enum16, IPv4, IPv6, UUID) embedded inside a composite literal, the driver wraps the formatter’s output in single quotes but does not escape its contents. If your returned string contains an unescaped single quote or backslash, the composite literal will be malformed and the server will reject the query. Top-level string parameters (not embedded in a composite) are used verbatim without wrapping, so escaping is not required there. Formatter precedence:
  1. IParameterFormatter (from QueryOptions.ParameterFormatter, falling back to ClickHouseClientSettings.ParameterFormatter). If it returns non-null, that value is used.
  2. Built-in type-specific formatting in HttpParameterFormatter.
The formatter is not consulted for null or DBNull values, those are always serialized as the ClickHouse null sentinel (\N).

Custom read value conversion

IReadValueConverter lets you transform values returned by the data reader after deserialization, without changing their CLR type. Typical uses: setting DateTime.Kind = Utc on a DateTime column that has no timezone, trimming or normalizing strings, or post-processing a JSON column before it reaches application code. Set ReadValueConverter on ClickHouseClientSettings to install a converter for all reads. The converter is invoked once per column per row through both the boxed (GetValue) and generic (GetFieldValue<T>) paths. When no converter is set, there is zero overhead — the reader returns values directly. Using DictionaryReadValueConverter for simple per-CLR-type conversion:
Values whose runtime CLR type is not registered with For<T> pass through unchanged. The dispatch is by exact CLR type, so register the actual type the reader produces (e.g., For<JsonObject> for a JSON column in JsonReadMode.Binary). Custom IReadValueConverter for advanced scenarios: If you need to dispatch on the ClickHouse-side type string (for example, to distinguish DateTime from DateTime('UTC') — both surface as the same CLR type), implement IReadValueConverter directly:
The converter must preserve the runtime CLR type; column metadata (GetFieldType, GetSchemaTable) is not rerouted through it and must remain consistent with what is returned. You can also set a converter per-query via QueryOptions.ReadValueConverter; when set, it takes precedence over the client-level converter. Dispatch boundary: The converter is invoked once per column with the entire deserialized cell value, it does not recurse into composite containers. For an Array(Int32) column the value passed in is an int[]; for Tuple(Int32, String) it is an ITuple. Which overload runs: Both overloads must agree, because the one the driver calls depends on how the caller read the column:
  • ConvertValue<T> — the typed accessors GetByte, GetSByte, GetInt16/32/64, GetUInt16/32/64, GetFloat, GetDouble, GetGuid, GetDateTime, GetIPAddress, GetBigInteger and GetFieldValue<T>, plus every box-free column on the POCO read path.
  • ConvertValue (boxed) — GetValue, GetValues, the indexers, GetChar, GetTuple, and the coercing paths in GetBoolean, GetDecimal and GetString.
IsDBNull runs no converter at all: it reads the null flag directly, so a converter can never change whether a value counts as null. TryGetEnumOrdinal likewise bypasses it — see reading an enum’s ordinal. The converter works with the ADO.NET ClickHouseConnection path — the settings are inherited by connections created from the client.

Raw streaming

Use ExecuteRawResultAsync to stream query results in a specific format directly, bypassing the data reader. This is useful for exporting data to files or passing through to other systems:
Common formats: JSONEachRow, CSV, TSV, Parquet, Native. See the formats documentation for all options.

Per-query transport compression

By default, the client negotiates zstd, lz4, gzip, deflate when Compression=true (the connection-string default) and decodes the stream itself, transparently. For raw exports (e.g. Parquet, Arrow, Native) you may want to negotiate a different codec (e.g. zstd or lz4) to trade CPU for bandwidth without changing the connection-wide setting. QueryOptions.AcceptEncoding and ClickHouseCommand.AcceptEncoding set the HTTP Accept-Encoding header for a single request, replacing whatever default was attached, and force enable_http_compression=1 on the URL (which ClickHouse requires before it will honor Accept-Encoding).

HttpClient configuration

Nothing to configure: the HttpClient the driver builds leaves AutomaticDecompression at DecompressionMethods.None and the driver decodes responses itself, so Content-Encoding is never stripped behind your back and a raw body reaches you exactly as the server sent it.
If you supply your own HttpClient, leave AutomaticDecompression off too. It is not only a response-side setting: at send time the handler adds every algorithm in its mask that is missing from the outgoing Accept-Encoding. A handler with GZip | Deflate therefore turns an explicit AcceptEncoding = "lz4" into lz4, gzip, deflate and an explicit "identity" into identity, gzip, deflate on the wire — and since ClickHouse resolves the header by its own fixed codec preference (ignoring order and q-values), it can answer with a codec you never asked for, which the handler then decodes and strips so you cannot even see it happened. Leaving the mask off keeps the offer exactly what you chose.
If AcceptEncoding requests a codec the driver cannot decode (snappy), only ExecuteRawResultAsync is safe. ExecuteReaderAsync, ExecuteScalarAsync and ExecuteNonQueryAsync fail with a NotSupportedException naming the codec (previously they parsed the compressed bytes as the result format and produced garbage).

Error bodies

When the server responds with a 4xx/5xx and enable_http_compression=1 was set, it compresses the error body with the same codec it would have used for a successful response. The driver decodes those for every codec it supports (lz4, zstd, gzip, deflate, br/brotli) so the message on ClickHouseServerException is readable. For anything else (snappy, …) it returns a placeholder naming the codec and pointing at system.query_log for the original error text.

Response decompression

Accept-Encoding only asks the server to compress the response — something still has to decode it. The driver does that itself, from the response’s Content-Encoding, so all the normal read APIs (ExecuteReaderAsync, ExecuteScalarAsync, ExecuteNonQueryAsync, QueryAsync<T>, Dapper, EF Core, linq2db) work over a compressed response with nothing to configure. It decodes lz4, zstd, gzip, deflate and br; snappy is not supported. By default the driver advertises zstd, lz4, gzip, deflate, and ClickHouse answers with zstd. To choose differently, set Accept-Encoding yourself — client-wide:
per query, which takes precedence:
or in the connection string, for ORM users who never touch ClickHouseClientSettings:
Setting it also forces enable_http_compression=1 on the URL, which ClickHouse requires before it honors the header at all — including when UseCompression is false, since naming a codec explicitly is taken as asking for one. With no value set, UseCompression=false sends no Accept-Encoding at all. Accept-Encoding can be set in four places. The first of these that names a codec wins:
  1. QueryOptions.AcceptEncoding (or ClickHouseCommand.AcceptEncoding)
  2. CustomHeaders["Accept-Encoding"] on the query
  3. CustomHeaders["Accept-Encoding"] on the client
  4. ClickHouseClientSettings.AcceptEncoding, or the AcceptEncoding connection-string keyword
If none of them does, the driver sends its default list. A value that names no codec (null, empty, whitespace, or only commas) counts as unset and falls through to the next place. To turn off compression, use identity. The server, not the client, picks the codec. ClickHouse scans Accept-Encoding for tokens in its own fixed preference order — zstd > br > lz4 > snappy > gzip > deflate — and ignores both the order you list them in and any q-values. So the header is a capability announcement rather than a demand, and the only way to steer the choice is which tokens you leave out. The default includes zstd, so a default query is answered with zstd; the remaining tokens act as a fallback. br is decodable but not advertised by default. How the codecs compare on payload size, server CPU and client CPU depends on your data, your link and the server’s http_zlib_compression_level (shipped default: 3) — see Tuning compression.
  • http_zlib_compression_level. That setting applies to every HTTP codec, and the default value is 3. This value should be tuned based on your data, link speed, and CPU use.
  • A CPU-bound client on a fast link. The driver decodes the response body on the calling thread, so when the network is not the bottleneck, client-side decode speed could act as the limiting factor.
Ask for a different codec per query, or client-wide, whenever either applies:
Because the decision is made from the response, a body is decoded whenever its Content-Encoding says so, whatever was requested: absent or identity passes through untouched, a supported codec is decoded, and anything else raises an error naming it. There is no risk of double-decoding — if a caller-supplied handler’s AutomaticDecompression has already decoded a body it also strips Content-Encoding, so the driver sees plaintext and leaves it alone. Raw results advertise no codec. ExecuteRawResultAsync (and the public PostStreamAsync / InsertRawStreamAsync) hand their body to you verbatim, so unless you name a codec yourself they ask for none at all — nothing in the driver decodes such a body, so offering a codec there would silently turn an export into a compressed file. The rule is therefore simple and independent of how any HttpClient is configured: a verbatim body arrives exactly as the server sent it, and the server sends plaintext unless you asked for a codec. Asking for one (client-wide or per query) is how you export compressed bytes on purpose. An explicit AcceptEncoding (at either level) still applies to raw requests, and ClickHouseRawResult.ReadDecompressedStreamAsync() decodes the result when you want that; ReadAsStreamAsync, ReadAsByteArrayAsync, ReadAsStringAsync and CopyToAsync always return the bytes exactly as they arrived.
Read the returned stream to completion before it goes out of scope, as above. When the response is compressed you get a decoder created with leaveOpen, so disposing it leaves the response intact; when it is not compressed you get the HTTP content stream itself, so disposing it ends the body. Either way the ClickHouseRawResult owns the response — don’t call its other read members after the stream has been disposed. Disposing the ClickHouseRawResult is always required and on its own sufficient: it releases both the response and any decoder inserted here (decoders hold pooled buffers). The await using above is therefore optional, and safe to keep. Repeated sequential calls hand back the same stream; the type is not safe to use concurrently. See Select_007_ResponseCompression.cs for a runnable example.

Insert (request) compression

Zstd is the default codec for inserts: InsertOptions.Compressor starts as ZstdCompressor.Default, which is zstd at level 3. Set it to another compressor to change the codec, or to null to send the body uncompressed.
Four codecs ship with the driver. Each has a Default instance, and a constructor that takes a level and the size of the write buffer:
Share compressor instances. Every Default is one shared instance, and all four compressors are safe to use from several threads at the same time — which is what happens when InsertOptions.MaxDegreeOfParallelism is above 1, since one insert uses one compressor for every batch. None of them implements IDisposable. Construct your own instance once and reuse it, the same way Default is used.
IClickHouseCompressor is public, and an implementation must provide only two members:
The server must accept the Content-Encoding you name. The remaining members — Decompress, MethodByte, MaxEncodedLength, Encode and Decode — have default implementations that throw NotSupportedException, so override only the ones your codec needs. Implement Decompress to decode response bodies as well as compress requests, and raise InvalidDataException from the stream it returns when a body is corrupt or in the wrong format. InsertOptions.Compressor governs a binary insert only. The driver’s other request bodies are compressed by different rules, and none of them goes through it:
  • Every SQL-text request (ExecuteReaderAsync, ExecuteScalarAsync, ExecuteNonQueryAsync, QueryAsync<T>, ExecuteRawResultAsync, the ADO.NET layer) sends its statement with Content-Encoding: gzip whenever UseCompression is true — i.e. by default. The codec is not configurable: AcceptEncoding steers the response only, so gzip or nothing is the choice. Compression=false sends the statement in the clear. Statements are small, so this is rarely worth thinking about — but it is worth knowing when you are watching requests in a proxy or a packet capture.
  • A multipart body — a query whose parameters are sent as form data (UseFormDataParameters=true) — is always sent uncompressed, whatever UseCompression says.
  • A raw upload (InsertRawStreamAsync, PostStreamAsync) takes its own per-call flag and consults neither UseCompression nor InsertOptions.Compressor: gzip when the flag is set, uncompressed otherwise. Note that InsertRawStreamAsync’s useCompression parameter defaults to true, so a raw upload is gzipped unless you pass false — even with Compression=false on the client.

Tuning compression

Compression trades CPU for bytes. Whether that is a win depends almost entirely on how fast your link is relative to how fast the codec runs. There is no setting that is right for everyone.

The one number that decides it

Compressing is worth it as long as the codec is faster than the network. That threshold is lower than most people expect on the read path, because ClickHouse compresses HTTP responses single-threaded in the output buffer. Measured on a 16-vCPU ClickHouse Cloud service (hits, RowBinary, level 3), the server produces compressed output at roughly 100-200MB/s. So for a large result, and assuming a single query being processed at a time, compression stops paying somewhere around 100 MB/s. A single HTTPS stream inside one cloud region commonly exceeds that, while anything crossing the public internet, a VPN or a region boundary is usually below it. The insert path tolerates compression to higher link speeds, because your client compresses on a core of its own and is typically faster than the server’s response compression.

Rough guide by deployment

Three things this table does not capture:
  • Egress cost: if you are billed for data transfer, bytes have a price beyond latency, and that pushes toward higher compression regardless of link speed.
  • Small results: all of the above concerns large payloads. For small responses the codec barely matters and per-request overhead dominates.
  • Parallel inserts move the insert thresholds up. Every throughput figure above is for a single thread. InsertOptions.MaxDegreeOfParallelism defaults to 1, but raising it compresses batches concurrently, so the client’s aggregate encode rate scales roughly with the cores you give it. So on a fast link a parallel insert can stay worth compressing well past the speed at which a single-threaded one stops being worth it. Treat the insert rows in the table as a floor, and if you already batch in parallel, retest before concluding that your link is too fast for compression.
The read path only parallelizes across multiple queries.

Choosing a codec

Levels

Response compression is governed by a single server setting, http_zlib_compression_level, which applies to every HTTP codec, not just zlib. It defaults to 3. Leave it alone unless you have measured a reason. Above the default it buys very little size for a lot of CPU (for zstd, 3 → 6 roughly doubles server CPU for ~14% fewer bytes), and br becomes pathological. Below it, at level 1, the picture genuinely changes: lz4 gets much cheaper and zstd loses its CPU advantage over it. Set it per query if you need to:

Measuring your own crossover

The quickest way to optimize your choice of codec and compression level is to time the same query at a few codecs and compare.
For the server’s side of the same picture, read ProfileEvents back out of system.query_log — set QueryOptions.QueryId so you can find the row:
One trap if you benchmark this yourself: a bare LIMIT n without ORDER BY returns different rows on every run, so each repetition compresses different data and the ratios become noise. Compare against a fixed result set.

Raw stream insert

Use InsertRawStreamAsync to insert data directly from file or memory streams in formats like CSV, JSON, Parquet, or any supported ClickHouse format. Insert from a CSV file:
The driver takes ownership of the stream. InsertRawStreamAsync and PostStreamAsync dispose the stream you give them once the request finishes, whether it succeeded or failed. Do not dispose it yourself and do not reuse it afterwards — which is why the example above does not wrap the FileStream in a using.A using of your own runs after the driver has already disposed the stream. For a FileStream or MemoryStream that second call is harmless, but for a stream whose Dispose returns a pooled buffer or drops a reference count it releases the resource twice.Ownership passes only once the arguments are accepted: if the call throws ArgumentException or ArgumentNullException for a missing table, stream or format, the stream is still yours.
See the format settings documentation for options to control data ingestion behavior.

More examples

For additional practical usage examples, see the examples directory in the GitHub repository.

ADO.NET

The library provides full ADO.NET support through ClickHouseConnection, ClickHouseCommand, and ClickHouseDataReader. This API is required for ORM integration (Dapper, Linq2db) and when you need standard .NET database abstractions.

Lifetime management with ClickHouseDataSource

Always create connections from a ClickHouseDataSource to ensure proper lifetime management and connection pooling. The DataSource manages a single ClickHouseClient internally, and all connections share its HTTP connection pool.
For dependency injection:
Do not create ClickHouseConnection directly in production code. Each direct instantiation creates a new HTTP client and connection pool, which can lead to socket exhaustion under load:
Instead, always use ClickHouseDataSource or share a single ClickHouseClient instance.

Using ClickHouseCommand

Create commands from a connection to execute SQL:
Command methods:
  • ExecuteNonQueryAsync() - For INSERT, UPDATE, DELETE, DDL statements
  • ExecuteScalarAsync() - Returns first column of first row
  • ExecuteReaderAsync() - Returns a ClickHouseDataReader for iterating results

Using ClickHouseDataReader

The ClickHouseDataReader provides typed access to query results:

Reading an enum’s ordinal

An Enum8 or Enum16 column materializes as its label: GetFieldType reports string, and GetString, GetValue and GetFieldValue<string> all give you the label. The numeric accessors throw InvalidCastException on an enum column, because the value being held is a string. Use TryGetEnumOrdinal to get the number behind the label:
It returns true and sets value for an Enum8/Enum16 column, and for a Nullable(Enum...) column whose cell is not NULL. It returns false, with value set to 0, for a NULL cell or for any column that is not an enum. The ordinal is the signed value from the wire, so it can be negative, and an Enum16 ordinal can be larger than a byte.

Best practices

Connection lifetime and pooling

ClickHouse.Driver uses System.Net.Http.HttpClient under the hood. HttpClient has a per-endpoint connection pool. As a consequence:
  • Database sessions are multiplexed through HTTP connections managed by the connection pool.
  • HTTP connections are recycled automatically by the pool.
  • Connections can stay alive after ClickHouseClient or ClickHouseConnection objects are disposed.
Recommended patterns:
When using a custom HttpClient or HttpClientFactory, ensure that the PooledConnectionIdleTimeout is set to a value smaller than the server’s keep_alive_timeout, in order to avoid errors due to half-closed connections. The default keep_alive_timeout for Cloud deployments is 10 seconds.
Avoid creating multiple ClickHouseClient or standalone ClickHouseConnection instances without a shared HttpClient. Each instance creates its own connection pool.

DateTime handling

  1. Use UTC whenever possible. Store timestamps as DateTime('UTC') columns and use DateTimeKind.Utc in your code. This eliminates timezone ambiguity.
  2. Use DateTimeOffset for explicit timezone handling. It always represents a specific instant and includes the offset information.
  3. Specify timezone in SQL type hints. When using parameters with Unspecified DateTime values targeting non-UTC columns, include the timezone in the SQL:

Async inserts

Async inserts shift batching responsibility from the client to the server. Instead of requiring client-side batching, the server buffers incoming data and flushes it to storage based on configurable thresholds. This is useful for high-concurrency scenarios like observability workloads where many agents send small payloads. Enable async inserts via CustomSettings or the connection string:
Two modes (controlled by wait_for_async_insert):
With wait_for_async_insert=0, errors only surface during flush and can’t be traced back to the original insert. The client also provides no backpressure, risking server overload.
Key settings:

Sessions

Only enable sessions when you need stateful server-side features, e.g.:
  • Temporary tables (CREATE TEMPORARY TABLE)
  • Maintaining query context across multiple statements
  • Session-level settings (SET max_threads = 4)
When sessions are enabled, requests are serialized to prevent concurrent use of the same session. This adds overhead for workloads that don’t require session state.
Using ADO.NET (for ORM compatibility):

Supported data types

ClickHouse.Driver supports all ClickHouse data types. The tables below show the mappings between ClickHouse types and native .NET types when reading data from the database.

Type mapping: reading from ClickHouse

Integer types


Floating point types


Decimal types

Decimal type conversion is controlled via the UseCustomDecimals setting.

Boolean type


String types

By default, both String and FixedString(N) columns are returned as string. Set ReadStringsAsByteArrays=true in your connection string to read them as byte[] instead. This is useful when storing binary data that may not be valid UTF-8.The setting reaches strings nested inside other types too, so Array(String) reads as byte[][] and Map(String, String) as Dictionary<byte[], byte[]> — keys included. The one exception is a JSON column, whose string leaves are always text; see JSON type.

Date and time types

ClickHouse stores DateTime and DateTime64 values internally as Unix timestamps (seconds or sub-second units since epoch). While the storage is always in UTC, columns can have an associated timezone that affects how values are displayed and interpreted. When reading DateTime values, the DateTime.Kind property is set based on the column’s timezone: For non-UTC columns, the returned DateTime represents the wall-clock time in that timezone. Use ClickHouseDataReader.GetDateTimeOffset() to get a DateTimeOffset with the correct offset for that timezone:
For columns without an explicit timezone (i.e., DateTime instead of DateTime('Europe/Amsterdam')), the driver returns a DateTime with Kind=Unspecified. This preserves the wall-clock time exactly as stored without making assumptions about timezone. If you need timezone-aware behavior for columns without explicit timezones, either:
  1. Use explicit timezones in your column definitions: DateTime('UTC') or DateTime('Europe/Amsterdam')
  2. Apply the timezone yourself after reading.

JSON type

The return type for JSON columns is controlled by the JsonReadMode setting:
  • Binary (default): Returns System.Text.Json.Nodes.JsonObject. Provides structured access to JSON data, but specialized ClickHouse types (like IP addresses, UUIDs, large decimals) are converted to their string representations within the JSON structure.
  • String: Returns the raw JSON as a string. Preserves the exact JSON representation from ClickHouse, which is useful when you need to pass the JSON through without parsing, or when you want to handle deserialization yourself.
None is a third mode. It reads exactly as Binary does, but sends no server setting with the query — use it on a connection that is not allowed to set one. A path declared in the column type is a typed path; any other path in the document is a dynamic path. The two differ when a value is null. A typed path always appears in the JsonObject. Declared Nullable(T) or Dynamic, it comes back as a JSON null both when the stored value is null and when the document has no such path — the two cases are indistinguishable:
Declared with a non-nullable type, an absent path takes the type’s default instead — JSON(x String) gives {"x":""} and JSON(x Int64) gives {"x":0}. A dynamic path whose value is null is dropped from the object entirely, so ContainsKey returns false for it. Reading {"x":null} from a plain JSON column gives {}. Nested typed paths build their parents, so JSON(a.b Nullable(Int64)) yields {"a":{"b":null}} even for an empty document.
This is what the server itself renders, so Binary and String modes now agree. Before 1.4.0 a typed path holding null was dropped from the JsonObject, which made {"x":null} read back as {} — and for a nested path such as JSON(a.b Nullable(Int64)) the whole a subtree disappeared.
String leaves inside a JSON column are always returned as text, whatever ReadStringsAsByteArrays is set to — JsonValue has no byte-array form, so a byte[] would render as base64. This holds for String, FixedString, and for those wrapped in LowCardinality, Nullable or SimpleAggregateFunction, and for strings inside Array and Map, map keys included.
A byte array the JSON reader cannot see the type of does still render as base64: a Variant or Dynamic typed path holds a value whose type is known only for each row, so a string under Variant(Array(UInt8), String) comes back base64-encoded. That is the same in both settings.A JSON map key type other than exactly StringMap(LowCardinality(String), String), for example — throws NotSupportedException.
Overlapping paths
ClickHouse accepts a column which declares a path both as a value and as the parent of another path, for example JSON(a Int64, a.b Int64). Both paths are present in every row, so the server renders the row with a duplicate key: {"a":0,"a":{"b":7}}. A JsonObject cannot hold two values for one key, so JsonReadMode.Binary throws a SerializationException naming the two paths. The same holds where the value is a Map, as in JSON(a Map(String, Int64)) read from a row which also has a dynamic a.b. This applies only where both sides hold a value in that row. A side which holds nothing — a null, an empty object, or a subtree whose values are all null — gives way to the side which has the data, whichever of the two paths the server sends first. An overlap declared with Nullable types therefore fills in one side per row and reads without error: JSON(a Nullable(Int64), a.b Nullable(Int64)) gives {"a":5} and {"a":{"b":7}} as expected. Read such a column with JsonReadMode.String to get the server’s JSON text unchanged, duplicate key included. Set AllowDuplicateJsonKeys to keep reading the column as a JsonObject instead of throwing. The driver then keeps whichever of the two values the row carries last and drops the other, so the result is lossy: JSON(a Int64, a.b Int64) holding {"a.b":7} reads as {"a":0}. A path which holds a value and whose parent holds a scalar or an array still throws, because a subtree cannot be placed under either.

Map type

A ClickHouse Map(K, V) is physically an Array(Tuple(K, V)) and can hold several entries with the same key. A Dictionary cannot, so in the default mode a repeated key keeps only its last value and the earlier pairs are dropped. The MapReadMode setting selects the representation:
  • Dictionary (default): Returns Dictionary<K, V>.
  • KeyValuePairs: Returns List<KeyValuePair<K, V>> in the order the server sent the pairs, so every pair is preserved, including entries which repeat a key.
The mode selects the framework type of a Map column, so it also applies to GetFieldValue<T>, the schema types the driver reports, and POCO property mapping. It applies wherever a map appears in a column’s type tree — Array(Map(...)), Map(K, Map(...)), Tuple(..., Map(...)) and Dynamic included. Both representations are accepted on the write path in either mode — see writing maps.

Other types

The Dynamic and Variant types will be converted to the corresponding type for the actual underlying type in each row.

Geometry types

The Geometry type is a Variant type that can hold any of the geometry types. It will be converted to the corresponding type.

Type mapping: writing to ClickHouse

When inserting data, the driver converts .NET types to their corresponding ClickHouse types. The tables below show which .NET types are accepted for each ClickHouse column type.

Integer types


Floating point types


Boolean type


String types


Date and time types

Out-of-range valuesOn the binary write path, Date, Date32, DateTime, and DateTime32 values outside their supported range throw ArgumentOutOfRangeException at Write time, naming the column type and the supported range. Previously, out-of-range values could be silently truncated through a 32-bit integer and reinterpreted by the server, producing real-but-wrong timestamps.
The driver respects DateTime.Kind when writing values: DateTimeOffset values always preserve the exact instant. Example: UTC DateTime (instant preserved)
Example: unspecified DateTime (wall-clock time)
Recommendation: for simplest and most predictable behavior, use DateTimeKind.Utc or DateTimeOffset for all DateTime operations. This ensures your code works consistently regardless of server timezone, client timezone, or column timezone.

HTTP parameters vs bulk copy

There is an important difference between HTTP parameter binding and bulk copy when writing Unspecified DateTime values: Bulk Copy knows the target column’s timezone and correctly interprets Unspecified values in that timezone. HTTP Parameters do not automatically know the column timezone. You must specify it in the SQL type hint:

Decimal types


JSON type

The behavior when writing JSON is controlled by the JsonWriteMode setting:
  • String (default): Accepts string, JsonObject, JsonNode, or any object. All inputs are serialized via System.Text.Json.JsonSerializer and sent as JSON strings for server-side parsing. This is the most flexible mode and works without type registration.
  • Binary: Only accepts registered POCO types. Data is converted to ClickHouse’s binary JSON format client-side with full type hint support. Requires calling connection.RegisterJsonSerializationType<T>() before use. Writing string or JsonNode values in this mode throws ArgumentException.
When a JSON column has type hints (e.g., JSON(id UInt64, price Decimal128(2))), the driver uses these hints to serialize values with full type fidelity. This preserves precision for types like UInt64, Decimal, UUID, and DateTime64 that would otherwise lose precision when serialized as generic JSON. POCOs can be written to JSON columns in two ways depending on the JsonWriteMode: String mode (default): POCOs are serialized via System.Text.Json.JsonSerializer. No type registration is required. This is the simplest approach and works with anonymous objects. Binary mode: POCOs are serialized using the driver’s binary JSON format with full type hint support. Types must be registered with connection.RegisterJsonSerializationType<T>() before use. This mode supports custom path mappings via attributes:
  • [ClickHouseJsonPath("path")]: Maps a property to a custom JSON path. Useful for nested structures or when the property name differs from the desired JSON key. Only works in Binary mode.
  • [ClickHouseJsonIgnore]: Excludes a property from serialization. Only works in Binary mode.
Property name matching with column type hints is case-sensitive. A property UserId will only match a hint defined as UserId, not userid. This matches ClickHouse behavior which allows paths like userName and UserName to coexist as separate fields. Limitations (Binary mode only):
  • POCO types must be registered on the connection with connection.RegisterJsonSerializationType<T>() before serialization. Attempting to serialize an unregistered type throws ClickHouseJsonSerializationException.
  • Dictionary and array/list properties require type hints in the column definition to be serialized correctly. Without hints, use String mode instead.
  • Null values in POCO properties are only written when the path has a Nullable(T) type hint in the column definition. ClickHouse doesn’t allow Nullable types inside dynamic JSON paths, so un-hinted null properties are skipped.
  • ClickHouseJsonPath and ClickHouseJsonIgnore attributes are ignored in String mode (they only work in Binary mode).

Other types


Geometry types


Not supported for writing


Nested type handling

ClickHouse nested types (Nested(...)) can be read and written using array semantics.

Logging and diagnostics

The ClickHouse .NET client integrates with the Microsoft.Extensions.Logging abstractions to offer lightweight, opt-in logging. When enabled, the driver emits structured messages for connection lifecycle events, command execution, transport operations, and bulk insert operations. Logging is entirely optional—applications that do not configure a logger continue to run without additional overhead.

Quick start

Using appsettings.json

You can configure logging levels using standard .NET configuration:

Using in-memory configuration

You can also configure logging verbosity by category in code:

Categories and emitters

The driver uses dedicated categories so that you can fine-tune log levels per component:

Example: Diagnosing connection issues

This will log:
  • HTTP client factory selection (default pool vs single connection)
  • HTTP handler configuration (SocketsHttpHandler or HttpClientHandler)
  • Connection pool settings (MaxConnectionsPerServer, PooledConnectionLifetime, etc.)
  • Timeout settings (ConnectTimeout, Expect100ContinueTimeout, etc.)
  • SSL/TLS configuration
  • Connection open/close events
  • Session ID tracking

Debug mode: network tracing and diagnostics

To help with diagnosing networking issues, the driver library includes a helper that enables low-level tracing of .NET networking internals. To enable it you must pass a LoggerFactory with the level set to Trace, and set EnableDebugMode to true (or manually enable it via the ClickHouse.Driver.Diagnostic.TraceHelper class). Events will be logged to the ClickHouse.Driver.NetTrace category. Warning: this will generate extremely verbose logs, and impact performance. It isn’t recommended to enable debug mode in production.

OpenTelemetry

The driver provides built-in support for OpenTelemetry distributed tracing via the .NET System.Diagnostics.Activity API. When enabled, the driver emits spans for database operations that can be exported to observability backends like Jaeger or ClickHouse itself (via the OpenTelemetry Collector).

Enabling tracing

In ASP.NET Core applications, add the ClickHouse driver’s ActivitySource to your OpenTelemetry configuration:
For console applications, testing, or manual setup:

Span attributes

Each span includes standard OpenTelemetry database attributes plus ClickHouse-specific query statistics that can be used for debugging.

Configuration options

Control tracing behavior via ClickHouseDiagnosticsOptions:
Enabling IncludeSqlInActivityTags may expose sensitive data in your traces. Use with caution in production environments.

TLS configuration

When connecting to ClickHouse over HTTPS, you can configure TLS/SSL behavior in several ways.

Custom certificate validation

For production environments requiring custom certificate validation logic, provide your own HttpClient with a configured ServerCertificateCustomValidationCallback handler:
Important considerations when providing a custom HttpClient
  • Automatic decompression: leave AutomaticDecompression off. The driver decodes compressed responses itself, so it is not needed — and enabling it works against you on the request side: at send time the handler also adds every algorithm in its mask to the outgoing Accept-Encoding, widening whatever the driver advertised, so ClickHouse can answer with a codec you did not ask for. See Response decompression.
  • Idle timeout: Set PooledConnectionIdleTimeout smaller than the server’s keep_alive_timeout (10 seconds for ClickHouse Cloud) to avoid connection errors from half-open connections.

Performance tuning

This section describes how to use the client to achieve optimal performance, and the various options you can tune to make the client performant for your particular use case.

At a glance

| If you | Do this | |---|---|---| | Read rows into POCOs | Use QueryAsync<T>, not MapTo<T> | | Do large inserts | Increase InsertOptions.BatchSize | | Run an insert-heavy console or worker app | Turn on Server GC | | Read large results across a network | Keep response compression on (the default) | | Insert across a fast link | Try InsertOptions.Compressor = null | | Insert into the same table many times | Use UseSchemaCache or ColumnTypes | | Read very large results | Increase ReadBufferSize |

Reading: choose the materialization path

There are three ways to get a row out of a result, and they do not cost the same. Some of the paths box the results, causing increased allocations and decreased performance. For a 1,000,000-row read of 105 columns of the hits dataset:
ORMs get the fast path when they use typed accessors. linq2db registers GetInt64, GetDouble and GetDateTime for each column, so it reads without boxing. Code that reads through GetValue (including a dynamic result from Dapper) boxes each value. If an ORM query is hot and reads through GetValue, use QueryAsync<T> for that one query.

Inserting: batch size and parallelism

Batch size is the largest single control on insert throughput. InsertOptions.BatchSize defaults to 100,000 rows. Use large batches. For a 1,000,000-row insert, an increase from 10,000 to 100,000 rows for each batch gave: If you cannot control the batch size (for example when many small producers send rows independently) use async inserts and let the server do the batching. Parallel uploads. InsertOptions.MaxDegreeOfParallelism defaults to 1. Increase it to send batches at the same time. This helps most when compression is on, because each batch then compresses on its own thread. Sessions do not work with parallel inserts: either turn sessions off, or keep MaxDegreeOfParallelism = 1. Remove the schema probe. Each InsertBinaryAsync call sends a SELECT ... WHERE 1=0 query first, to find the column types. See Skipping the schema probe query to remove that round trip with ColumnTypes or UseSchemaCache.
The box-free insert path applies to the default RowBinary format. RowBinaryWithDefaults must examine each value to find the DBDefault marker, so it keeps the slower path.

Compression: the two directions disagree

Compression exchanges CPU for bytes. Whether that exchange is good depends on the direction of the transfer, the bandwidth of your connection to the ClickHouse server, how your data interacts with your chosen compression algorithm, and whether you have to pay for each byte transferred. Reads: keep compression on, unless your server is running on the same machine. This is the default. Compared with no compression, zstd at level 1 gave: Inserts: measure before you compress. The savings may not be high enough to justify turning it on. Also keep in mind that decompression will put additional load on the server; that load is modest for Zstd and LZ4 but can be high for other algorithms (eg Brotli). To turn insert compression off:
For codec selection, compression levels, and how to find your own crossover point, see Tuning compression.

Buffers

ReadBufferSize sets the size of the buffer that reads HTTP responses. It defaults to 64 KiB. The driver rents this buffer from a shared pool and returns it when it disposes the reader, so it is not an allocation for each query. Increase it to reduce the number of buffer refills on large results. The driver holds one buffer for each reader that is open at the same time, so memory use increases with the buffer size and with the number of concurrent readers.
Always dispose readers. A reader returns its pooled buffer and releases its HTTP connection when you dispose it. Abandoning a reader does not return the buffer to the pool and may leave the HTTP connection unavailable; ordinary garbage collection is not a substitute for disposal.

Runtime and GC

Turn on Server GC for insert-heavy applications. With the same code, and with the same number of allocated bytes, Workstation GC was up to 97% slower on inserts than Server GC.
ASP.NET Core projects set this already. Console applications, worker services, and most container images do not. The cause is the size of the generation 0 budget. Workstation GC uses a small budget, so the short-lived buffers that an insert creates do not die in generation 0. They move into generation 1 instead, which increases promotion and causes much more generation 2 work. On one insert case, generation 2 collections for each 1,000 operations were 4,000 with Server GC and 73,000 with Workstation GC.
Server GC is a throughput setting, not a latency setting. In the same measurements, Server GC spent less than half as much total time paused, but its individual pauses were longer (95th percentile 114.6 ms against 61.9 ms). If your service is sensitive to tail latency, measure both modes before you choose.

Latency: reuse connections

Establishing a new TCP connection and performing the TLS handshake takes a significant amount of time. Reusing connections will significantly cut down the latency of your queries.
  • Do not create a client for each request. Each new client with its own HttpClient creates a new connection pool, and pays for the handshake again. Use one ClickHouseClient for the life of the application. It is thread-safe and is made for singleton use.
  • For ADO.NET and ORMs, use ClickHouseDataSource, so that all connections share one pool.
For the full set of patterns, see Connection lifetime and pooling.

Measure it yourself

In many cases, performance will depend on the shape of your data, the speed of your link to the server, whether you want to trade off client CPU for server CPU (or the other way around), your hardware limitations, etc. It is therefore recommended that you measure performance yourself based on your data and environment. To see the server’s part of the work, set QueryOptions.QueryId and read the counters back:

ORM support

ORMs require the ADO.NET API (ClickHouseConnection). For proper connection lifetime management, create connections from a ClickHouseDataSource:

Dapper

ClickHouse.Driver works with Dapper. The driver automatically converts Dapper’s @parameter syntax to ClickHouse’s native {parameter:Type} syntax, with types inferred from .NET values. Use ClickHouseDataSource for proper connection lifetime management:

Parameter passing styles

All standard Dapper parameter styles are supported: Anonymous objects:
POCO classes:
Dictionary:
DynamicParameters (from dictionary or anonymous object):

Querying into POCOs

Dapper maps columns to properties by name (case-insensitive):

ClickHouse-native parameter syntax

When you need explicit type control, use ClickHouse’s {param:Type} syntax directly in the SQL with a Dictionary<string, object> for the parameter values. Don’t combine @param syntax and {param:Type} syntax for the same parameter.

WHERE IN

Dapper’s native IN expansion works:
Dapper rewrites this to WHERE id IN (@Ids1, @Ids2, @Ids3), and the driver converts each expanded parameter. ClickHouse’s has() with Array parameter also works:

Custom type handlers

Some ClickHouse types, eg ITuple, BigInteger, and ClickHouseDecimal need handlers registered at startup:
See the Dapper example for an example type handler implementation.

Dapper.Contrib

GetAll<T>() and Get<T>(id) work. Insert<T>() does not — it generates SQL Server syntax (SCOPE_IDENTITY, []). It is recommended to use the ClickHouseClient native InsertBinaryAsync method instead.
Property names must match ClickHouse column names exactly (case-sensitive).

Limitations

Linq2db

This driver is compatible with linq2db, a lightweight ORM and LINQ provider for .NET. See the project website for detailed documentation. Example usage: Create a DataConnection using the ClickHouse provider:
Table mappings can be defined using attributes or fluent configuration. If your class and property names match the table and column names exactly, no configuration is needed:
Querying:
Bulk Copy: Use BulkCopyAsync for efficient bulk inserts.

Entity Framework Core

The official Entity Framework Core provider for ClickHouse. Map C# classes to ClickHouse tables, query with LINQ, and insert data via SaveChanges — all using familiar EF Core patterns.
This provider is in active development. Current release supports LINQ queries (including JOINs, subqueries, and set operations), INSERT via SaveChanges / BulkInsertAsync, migrations with full DDL (CREATE / ALTER / DROP), and ClickHouse-specific table engine configuration. UPDATE / DELETE are not supported.

Installation

Requires .NET 10.0 and EF Core 10.

Quick start

Define your entity and DbContext, then query with LINQ:

Supported types

Use ClickHouseDecimal (from ClickHouse.Driver.Numerics) instead of decimal when you need the full precision of Decimal128/Decimal256 columns — .NET decimal is limited to 28–29 significant digits.

Supported LINQ operations

Queries: Where, OrderBy, Take, Skip, Select, First, Single, Any, All, Count, Distinct, AsNoTracking GROUP BY & aggregates: GroupBy with Count, LongCount, Sum, Average, Min, Max — including HAVING (.Where() after .GroupBy()), multiple aggregates in a single projection, and OrderBy on aggregate results. JOINs: Join (INNER), GroupJoin/SelectMany patterns (LEFT and CROSS). LEFT JOIN returns real null for non-matching rows (see LEFT JOIN null semantics below). Subqueries: correlated Contains / IN, Any / EXISTS, All, and scalar subqueries in projections. Set operations: Concat (→ UNION ALL), Union (→ UNION DISTINCT), Intersect, Except. Inline local collections: joins and Contains against in-memory collections (int[], List<T>, etc.) translate into a series of UNIONs. String methods: Contains, StartsWith, EndsWith, IndexOf, Replace, Substring, Trim/TrimStart/TrimEnd, ToLower, ToUpper, Length, IsNullOrEmpty, Concat (and + operator). Math functions: standard Math and MathF methods translated to their ClickHouse equivalents — arithmetic, logarithmic, trigonometric, and utility functions. The provider injects set_join_use_nulls=1 into every connection path automatically to match Entity Framework expectations on JOIN behavior. If your ClickHouse server or profile forbids changing this setting (e.g. a readonly=1 profile), opt out with:
With the opt-out enabled, LEFT JOIN returns ClickHouse column defaults and EF’s null-based navigation detection no longer works as expected. Use explicit comparisons against 0 / "" instead of == null.

Inserting data

SaveChanges uses the driver’s native InsertBinaryAsync API — RowBinary encoding with a compressed request body, far more efficient than parameterized SQL:
Entities transition from Added to Unchanged after save, just like any other EF Core provider. Batch size is configurable (default 1000):

Bulk insert

For high-throughput loads, use BulkInsertAsync instead of SaveChanges. This is an extension method on DbContext that bypasses EF Core’s change tracker, identity resolution, and state management entirely — it calls the driver’s InsertBinaryAsync directly with RowBinary encoding and a compressed request body. This makes it suitable for loading large datasets where you don’t need entity tracking after insert:
The input can be any IEnumerable<T> — it streams through the entities without loading them all into memory. The return value is the number of rows inserted. Entities are not attached to the DbContext after insert, so there is no AddedUnchanged state transition.

Enums

ClickHouse Enum8/Enum16 columns can be mapped as string properties or as C# enum types. When using C# enums, the provider automatically converts between the enum and its string representation:

Custom type conversions

EF Core’s ValueConverter system lets you map custom types to types the provider already supports. The provider never sees your custom type — EF Core converts at the boundary. Per-property conversion:
Reusable converter class:

Column type annotations

For scalar types like string, int, DateTime, etc., the provider infers the ClickHouse type automatically. For parameterized types and wrappers, you need to specify the ClickHouse type explicitly. Using data annotations (attributes):
Using the fluent API in OnModelCreating:
Nested wrappers like Array(Nullable(Int32)) and LowCardinality(Nullable(String)) are supported — the provider unwraps Nullable and LowCardinality automatically at every nesting level.

Variant and Dynamic columns

ClickHouse Variant(T1, T2, ...) and Dynamic columns map to object in .NET. Since object is too generic for automatic type inference, you must declare the store type explicitly via .HasColumnType():
When reading, the value is automatically deserialized to the corresponding .NET type for the stored discriminator (e.g. string, ulong, ulong[]).

JSON columns

The provider supports ClickHouse’s Json column type, mapping to System.Text.Json.Nodes.JsonNode (primary) or string (via automatic ValueConverter):
Reading and writing JSON works through both SaveChanges and BulkInsertAsync:
If you prefer raw JSON strings, map the property as string with a Json column type — the provider applies a ValueConverter automatically:
  • No JSON path translationentity.Data["name"] in LINQ does not translate to ClickHouse’s data.name SQL syntax. Filter on non-JSON columns and inspect JSON in memory.
  • NULL semantics — ClickHouse’s JSON type returns {} (empty object) for NULL values rather than SQL NULL.
  • Integer precision — ClickHouse JSON stores all integers as Int64. When reading via JsonNode, use GetValue<long>() rather than GetValue<int>().

Table engines

Configure ClickHouse table engines and engine-specific clauses via the ToTable(name, t => ...) fluent API. When no engine is configured, the provider defaults to MergeTree with ORDER BY derived from the entity’s primary key.
Supported engine families: Engine clauses: WithOrderBy, WithPartitionBy, WithPrimaryKey, WithSampleBy, WithTtl, WithSettings. All attach to the engine builder returned from HasXxxEngine(). Column-level features: HasCodec, HasTtl, HasComment, HasDefault — all participate in migrations. Data-skipping indexes — via HasIndex(...).HasSkippingIndexType(...):
Standard (non-skipping) indexes are silently ignored since ClickHouse has no equivalent. Unique indexes throw, as ClickHouse does not enforce uniqueness.

Migrations

Standard EF Core migrations workflow:
Supported operations:

Migration limitations

Beyond migrations, the provider also does not yet support:
  • UPDATE / DELETE
  • Transactions: BeginTransaction is a no-op. No support for ACID transactions in ClickHouse.
  • JSON path query translation: entity.Data["key"] in LINQ does not translate to ClickHouse’s data.key SQL syntax. Filter on non-JSON columns and inspect JSON in memory.

Limitations

Tuples with 8+ elements and a nested tuple in the last position

C# ValueTuple types with more than 7 elements use a compiler-generated nesting scheme: the 8th generic argument (TRest) is itself a ValueTuple holding the remaining elements. For example, (int, int, int, int, int, int, int, string, string) compiles to ValueTuple<int, int, int, int, int, int, int, ValueTuple<string, string>>. This creates an ambiguity when the ClickHouse column is an 8-element tuple where the last element is itself a tuple — e.g., Tuple(Int32, Int32, Int32, Int32, Int32, Int32, Int32, Tuple(String, String)). The driver cannot distinguish between:
  • A 9-element flat tuple (compiler-generated TRest nesting)
  • An 8-element tuple where the last element is a nested Tuple(String, String)
Both produce the same .NET type: ValueTuple<int, int, int, int, int, int, int, ValueTuple<string, string>>. The driver treats the 8th argument as TRest (i.e., flattens it), which means the 8-element-with-nested-tuple case will be serialized incorrectly. This affects both System.Tuple and ValueTuple since both use TRest nesting for >7 elements. Tuples with 7 or fewer elements, or tuples where the last element is not itself a tuple, are not affected. Workaround: Wrap the inner tuple in an extra layer so the driver can distinguish it from TRest nesting:

AggregateFunction columns

Columns of type AggregateFunction(...) can’t be queried or inserted directly. To insert:
To select:

Last modified on September 1, 2026