Open-source project
ClickHouse/clickhouse-rs avatar
ClickHouse/clickhouse-rs

clickhouse-rs trades schema validation for speed, and you choose

Official pure Rust typed client for ClickHouse DB

566 stars178 forksRustApache-2.0

At a glance

What is it?
The official Rust client for ClickHouse encodes rows with serde and speaks RowBinaryWithNamesAndTypes over HTTP, which buys clear schema mismatch errors at a measurable cost, and the crate lets you switch validation off per client. It is Apache-2.0 or MIT licensed, built on Tokio, and still no GitHub releases, so the crate version on crates.io is the version to track.
Who is it for?
Adopt clickhouse-rs if your service is written in Rust and already speaks ClickHouse over HTTP, and you want typed rows with serde derive rather than hand-built query strings. Do not adopt it expecting the native TCP protocol, since the documented formats are HTTP based and plans for Native over TCP are described as still to be implemented.
Can I use it commercially?
Yes. Apache-2.0 is a permissive licence: you can use, modify and sell software built on it, as long as you keep its copyright and licence notices.
Is it still maintained?
Yes. The repository last received commits 10 days ago.
What is it written in?
Mainly Rust, according to GitHub's language statistics.

Answers come from the project's GitHub data, last synced on October 23, 2026, and from our analysis. They are not legal advice.

Editorial analysis

Rows are serde structs, not query tuples

clickhouse-rs is the official pure Rust typed client for ClickHouse, and the typing comes from serde. Rows are encoded and decoded with serde, and the crate supports the serde attributes skip_serializing, skip_deserializing and rename, so an existing struct with database-flavoured field names can be adapted without a hand-written conversion layer.

The row type itself is derived. A struct that will be read back from a table derives the Row trait together with Deserialize, and the fields line up with the columns:

rust
use serde::Deserialize;
use clickhouse::Row;

#[derive(Row, Deserialize)]
struct MyRow<'a> {
    no: u32,
    name: &'a str,
}

The same pattern works in the other direction for inserts, where the struct derives Row and Serialize and owns its String rather than borrowing it. One thing to plan for is that column and field names must match, since the default format sends both names and types to the server, and a field called no in Rust has to line up with a column called no in the table.

The project documentation also points at a separate tool for the painful part of this work: ch2rs, described as useful to generate a row type from ClickHouse, so you can start from the existing schema rather than transcribing it by hand.

The format choice decides whether schema errors are readable

This is the central design decision in the crate and the one most likely to affect your application. The client uses the RowBinaryWithNamesAndTypes format over HTTP transport by default, and since version 0.14.0 that default carries database schema validation. The benefit is clearer error messages when a row type and the table schema disagree. The cost is performance, and the project documentation is unusually precise about how the cost compounds: with validation enabled the crate supports structs with correct field names and matching types but incorrect order of the fields, at the cost of an additional slight penalty of 5 to 10 percent.

The escape hatch is per client, not global:

rust
use clickhouse::Client;

let client = Client::default()
    .with_url("http://localhost:8123")
    .with_user("name")
    .with_password("123")
    .with_database("test");

Setting Client::with_validation(false) switches the client to plain RowBinary instead. The downside of plain RowBinary is concrete: a mismatch between the row type and the database schema results in a NotEnoughData error without specific details, so the failure moves from a readable message to a parsing error. The upside is a possible x1.1 to x3 improvement, which the documentation stresses depends heavily on the shape and volume of the dataset.

The advice in the project documentation is to measure the impact of validation in your own use case, and to write smoke tests confirming the row types match the schema if you plan to disable it.

Queries use placeholders, and a raw escape hatch for the awkward cases

The query API is built on named placeholders resolved at bind time. The ?fields placeholder is replaced with the field names of the row type, so the client knows the column list, and a plain ? is replaced with the values supplied in the following bind calls:

rust
async fn example(client: clickhouse::Client) -> clickhouse::error::Result<()> {
    let mut cursor = client
        .query("SELECT ?fields FROM some WHERE no BETWEEN ? AND ?")
        .bind(500)
        .bind(504)
        .fetch::<MyRow<'_>>()?;

    while let Some(row) = cursor.next().await? {
        println!("no: {}, name: {}", row.no, row.name);
    }
    
    Ok(())
}

There are conveniences around that core: fetch_one and fetch_all for a first row or all rows, and sql::Identifier for binding table names.

The interesting part is the escape hatch. client.query_raw(..) sends SQL to the server as-is, with no placeholder parsing at all: ? is not treated as a placeholder, ?? is not unescaped, and ?fields is not substituted. To parameterize such a query you are told to use server-side parameters in the {name: type} form via .param(..). This is the route for generated SQL, for queries where a literal question mark appears in a string, and for anything that needs the raw text to survive untouched.

One more behaviour deserves attention: a cursor can return an error even after it has produced some rows. The documented fix, given in the project documentation, is to enable server-side buffering with client.with_setting("wait_end_of_query", "1"), with the buffer_size setting mentioned as possibly useful too.

Inserts are atomic only under partition and block-size limits

The insert API is an explicit object rather than a one-liner. You open an insert for a table, write rows one at a time, and close it:

rust
async fn example(client: clickhouse::Client) -> clickhouse::error::Result<()> {
    let mut insert = client.insert::<MyRow>("some").await?;
    insert.write(&MyRow { no: 0, name: "foo".into() }).await?;
    insert.write(&MyRow { no: 1, name: "bar".into() }).await?;
    insert.end().await?;
    Ok(())
}

The end call matters. If it is not called the INSERT is aborted, which is a good default for a batch writer but a trap for code that returns early on an error path and forgets to close. Rows are sent progressively to spread network load rather than buffered and sent in one piece.

The atomicity rule is the part that causes real incidents. ClickHouse inserts batches atomically only if all rows fit in the same partition and their number is less than max_insert_block_size. Both conditions are outside your control at the client level, so a batch that spans partitions or exceeds the block size is not one transaction. Code that assumes a single insert equals a single atomic unit will be wrong under some schemas and right under others, which is the worst combination.

For continuous ingestion there is a separate inserter path behind the inserter feature, described in the documentation as infinite transactional inserting.

The inserter turns a stream into bounded transactions

The inserter API is built for a producer that never stops. It is configured with a pair of timeouts, a byte ceiling, a row ceiling and a period, and each commit returns statistics:

rust
use clickhouse::inserter::Inserter;
use std::time::Duration;

async fn example(client: clickhouse::Client) -> clickhouse::error::Result<()> {
    let mut inserter = client.inserter::<MyRow>("some")
        .with_timeouts(Some(Duration::from_secs(5)), Some(Duration::from_secs(20)))
        .with_max_bytes(50_000_000)
        .with_max_rows(750_000)
        .with_period(Some(Duration::from_secs(15)));
    
    inserter.write(&MyRow { no: 0, name: "foo".into() }).await?;
    inserter.write(&MyRow { no: 1, name: "bar".into() }).await?;
    let stats = inserter.commit().await?;
    if stats.rows > 0 {
        println!(
            "{} bytes, {} rows, {} transactions have been inserted",
            stats.bytes, stats.rows, stats.transactions,
        );
    }
    Ok(())
}

The timeouts are a pair, one for the time limit and one for the check interval, and the period sets how often a commit happens even if the other limits are not reached. The byte and row ceilings bound a single transaction, which is the client-side counterpart to the server-side max_insert_block_size limit that governs atomicity for the plain insert API.

The statistics returned by commit are the practical feedback loop for a pipeline. Counting transactions alongside bytes and rows tells you whether your limits are producing the transaction size you intended, and the repository keeps an inserter example under examples/ that the Cargo manifest ties to the inserter feature, so the feature gate and the sample stay in step.

TLS, compression, mocks and the missing native protocol

The remaining feature set is broad for a database client. TLS is supported through the native-tls and rustls-tls features, and the repository keeps a clickhouse_cloud example that the Cargo manifest pins to the rustls-tls feature, which is how the Cloud deployment path is exercised. Compression and decompression are supported for LZ4, LZ4HC and ZSTD. The API covers selecting, inserting and infinite transactional inserting, and there are mocks for unit testing, with a mock example gated behind the test-util feature.

That last point matters more than it looks. A client that talks to a live server in every test is a client whose tests are slow, flaky and dependent on a container. The examples directory shows the shape of the supported surface: custom HTTP clients, custom HTTP headers, Arrow interop in a separate ext-arrow workspace member, OpenTelemetry, query ids, session ids, server-side parameters, streaming arbitrary format rows, streaming into a file, async insert, ClickHouse settings, enums, new JSON data types, the Variant data type, and containers.

The gap is transport. The documented formats are RowBinaryWithNamesAndTypes and RowBinary over HTTP, and the project documentation states there are plans to implement the Native format over TCP. Until that lands, the client is an HTTP client, and the operational profile of the service behind it is part of your deployment decision rather than an implementation detail you can ignore.

A workspace crate with a dual licence and no tagged releases

The repository is a Cargo workspace rather than a single crate. The manifest lists ext-arrow, macros and types as workspace members, uses the rust-version-aware dependency resolver 3, and inherits most package metadata from the workspace table: authors, repository, homepage, edition, license and rust-version. The edition is 2024 and the minimum supported Rust version is 1.89.0, with a note to keep rust-toolchain.toml in step. The licence is MIT OR Apache-2.0, and both LICENSE-APACHE and LICENSE-MIT files are at the top of the repository.

The manifest version is 0.15.3, while the installation snippet in the project documentation still shows clickhouse = "0.14.2" for both the dependency and the dev-dependency. That gap is the kind of detail that catches people, since the project documentation is the first thing a new user reads and the manifest is the truth about what is published.

For local work the repository ships a docker-compose file that starts a ClickHouse server with a configurable version tag, exposes 8123 and 9000, sets a high file descriptor ulimit, and mounts a single node configuration and a users file. There are also benches for market data, NYC taxi data, numbers, and mocked insert and select, and CI, clippy, rustfmt and codecov configuration alongside an AI_POLICY file and a CODE_OF_CONDUCT.

On maintenance, the last push was on 2026-10-13 and the repository publishes no GitHub releases, so the crate version on crates.io is the only version marker to track.

Editorial conclusion

Adopt clickhouse-rs if your service is written in Rust and already speaks ClickHouse over HTTP, and you want typed rows with serde derive rather than hand-built query strings. Do not adopt it expecting the native TCP protocol, since the documented formats are HTTP based and plans for Native over TCP are described as still to be implemented. Before you disable validation, measure it on your own dataset, because the improvement is stated as somewhere between x1.1 and x3 and depends on shape and volume, and remember to add Client::with_setting("wait_end_of_query", "1") if you want a cursor error to arrive before the rows rather than after them.

Frequently asked questions

How do I add the ClickHouse Rust client to a project?

Add the dependency to Cargo.toml with clickhouse = "0.14.2" as the README shows, and add clickhouse = { version = "0.14.2", features = ["test-util"] } under dev-dependencies if you want the mocking support. The manifest in the repository is at version 0.15.3, so check crates.io for the current release.

How do I turn off schema validation in clickhouse-rs?

Call Client::with_validation(false) on the client. The client then switches to the plain RowBinary format, which the documentation says can bring an x1.1 to x3 improvement depending on the dataset, at the cost of schema mismatches surfacing as a NotEnoughData error without detail.

What happens to a clickhouse-rs insert if end() is not called?

The INSERT is aborted. ClickHouse also only makes a batch atomic when all rows fit in the same partition and their number stays under max_insert_block_size, so a single insert call is not always a single transaction.

Does clickhouse-rs support the native TCP protocol?

Not yet. The documented formats are RowBinaryWithNamesAndTypes and RowBinary over HTTP, and the README states there are plans to implement the Native format over TCP.

How do I use server-side parameters instead of client-side placeholders?

Use client.query_raw(..) to send SQL untouched, so a question mark is not parsed as a placeholder, and pass parameters with .param(..) using the {name: type} form. This is the documented route for queries that contain literal question marks or need raw SQL text.

Official sources

  1. ClickHouse/clickhouse-rs on GitHub
  2. Issues
  3. License: Apache-2.0
  4. Project website
  5. README
Add this badge to your README

If you maintain this project, the badge below links readers to this analysis and shows its maintenance status from the daily GitHub snapshot. Paste the markdown into your README; add ?metric=license or ?metric=stars to the image URL for a different field.

Add this badge to your README

markdown
[![Hysen Labs](https://hysenlabs.com/badge/clickhouse-clickhouse-rs.svg)](https://hysenlabs.com/projects/clickhouse-clickhouse-rs)

Was this useful?