How to Connect to ClickHouse from Rust

6 min readClickHouse

Rust has three ClickHouse drivers worth knowing about, and they don't even use the same network protocol. The official clickhouse crate talks to ClickHouse over HTTP (port 8123), klickhouse implements the native TCP protocol (port 9000), and the older clickhouse-rs crate is effectively dormant. Picking a driver here means picking a protocol, which is why the most common connection failure in Rust is pointing the right client at the wrong port.

The options

LibraryProtocol / portStatusVersion (as of July 2026)
clickhouseHTTP, 8123 (8443 TLS)Official, actively maintained by ClickHouse Inc0.15.1
klickhouseNative TCP, 9000Community, maintained0.15.3
clickhouse-rsNative TCP, 9000Dormant -- last stable release 20230.1.21

The short answer: use the official clickhouse crate unless you have a measured reason not to. It is maintained by ClickHouse Inc (the repository lives under the ClickHouse GitHub org), uses serde for row encoding, supports LZ4/ZSTD compression and TLS, and since 0.15 validates your row structs against the actual database schema. klickhouse is a reasonable pure-Rust native-protocol alternative. clickhouse-rs shows up in old tutorials but its last stable release was in 2023, with a 1.1.0 alpha that never landed -- don't start new projects on it.

The port trap, first

This is the #1 mistake, so it goes before any code. ClickHouse listens on two ports:

  • 8123 -- HTTP interface. This is what the official clickhouse crate uses.
  • 9000 -- native TCP protocol. This is what klickhouse and clickhouse-rs use (and the clickhouse-client CLI).

If you hand the official crate http://localhost:9000, or point klickhouse at 8123, you get connection resets or protocol garbage rather than a clear "wrong port" error. For ClickHouse Cloud, the HTTPS port is 8443 and the native TLS port is 9440.

clickhouse: the official crate

[dependencies]
clickhouse = "0.15"
serde = { version = "1", features = ["derive"] }
tokio = { version = "1", features = ["full"] }

A minimal connection and query:

use clickhouse::{Client, Row};
use serde::Deserialize;
 
#[derive(Row, Deserialize)]
struct TripSummary {
    pickup_zone: String,
    trips: u64,
}
 
#[tokio::main]
async fn main() -> clickhouse::error::Result<()> {
    let client = Client::default()
        .with_url("http://localhost:8123")
        .with_user("default")
        .with_password("secret")
        .with_database("nyc");
 
    let rows = client
        .query("SELECT pickup_zone, count() AS trips FROM trips \
                WHERE pickup_date >= ? GROUP BY pickup_zone ORDER BY trips DESC LIMIT ?")
        .bind("2026-01-01")
        .bind(10u8)
        .fetch_all::<TripSummary>()
        .await?;
 
    for r in rows {
        println!("{}: {}", r.pickup_zone, r.trips);
    }
    Ok(())
}

Things to notice:

  • Client wraps a connection pool internally -- create it once and clone it cheaply; clones share the pool.
  • Parameters bind with ? via .bind(). There is also a ?fields placeholder that expands to the field names of your Row struct, which keeps SELECT lists in sync with the struct.
  • Rows are plain structs deriving Row plus serde's Deserialize/Serialize. rename, skip_serializing and friends work.

Schema validation (0.15 behavior change)

Since 0.15, the client uses the RowBinaryWithNamesAndTypes format by default and validates your struct against the server's schema -- a mismatched column type now produces a clear error instead of silently misparsed data or a vague NotEnoughData. You can switch validation off (with_validation(false)), which drops back to plain RowBinary and can be 1.1-3x faster on some datasets, but do that only with smoke tests in place; the failure mode without validation is deliberately unhelpful.

Batch inserts

ClickHouse creates a new data part per INSERT, so row-at-a-time inserts eventually fail with TOO_MANY_PARTS. The insert API is built around batches:

let mut insert = client.insert::<TripEvent>("trip_events").await?;
for event in events {
    insert.write(&event).await?;
}
insert.end().await?; // without end(), the INSERT is aborted

Rows are streamed progressively, and the batch is atomic only if all rows land in one partition and fit within max_insert_block_size. For sustained trickle ingestion, either buffer application-side (the crate's inserter feature adds a time/size-threshold buffering wrapper) or enable asynchronous inserts server-side with async_insert = 1 -- and keep wait_for_async_insert = 1 unless you can afford to lose acknowledged rows.

TLS and ClickHouse Cloud

The crate ships TLS behind feature flags -- pick one of native-tls or rustls-tls:

clickhouse = { version = "0.15", features = ["rustls-tls"] }
let client = Client::default()
    .with_url("https://abc123.eu-west-1.aws.clickhouse.cloud:8443")
    .with_user("default")
    .with_password(std::env::var("CLICKHOUSE_PASSWORD").unwrap());

Cloud instances idle-suspend on the lower tiers; the first query after a pause can take several seconds while the instance wakes. That's the service, not your connection code.

klickhouse: native protocol, pure Rust

[dependencies]
klickhouse = "0.15"
use klickhouse::{Client, ClientOptions, Row};
use serde::Deserialize;
 
#[derive(Row, Deserialize)]
struct TripSummary {
    pickup_zone: String,
    trips: u64,
}
 
#[tokio::main]
async fn main() -> anyhow::Result<()> {
    let client = Client::connect("127.0.0.1:9000", ClientOptions {
        username: "default".into(),
        password: "secret".into(),
        default_database: "nyc".into(),
        ..Default::default()
    })
    .await?;
 
    let rows = client
        .query_collect::<TripSummary>(
            "SELECT pickup_zone, count() AS trips FROM trips GROUP BY pickup_zone LIMIT 10",
        )
        .await?;
    println!("{} zones", rows.len());
    Ok(())
}

Note the port: 9000, native protocol. klickhouse is async, serde-derive based, and has one documented gap worth knowing: it does not support Enum8/Enum16 columns (the README suggests LowCardinality instead). If your schema leans on enums, that's a hard blocker.

Choose klickhouse when you specifically want the native protocol (e.g. to match clickhouse-client behavior, or HTTP is blocked between your app and the server). For most workloads the official HTTP crate performs comparably and gets more maintenance attention.

What about connection pooling?

Unlike PostgreSQL drivers, you generally don't add a pooler crate here. The official crate's Client multiplexes over an internal HTTP connection pool, and ClickHouse itself prefers a small number of connections running large scans over hundreds of tiny ones -- the same reasoning as our Java and Node ClickHouse guides. Clone the client, don't wrap it in bb8/deadpool.

Common errors

ErrorCauseFix
Connection reset / garbled responseHTTP client pointed at 9000, or native client at 8123Official crate → 8123/8443; klickhouse → 9000/9440
TOO_MANY_PARTSRow-at-a-time inserts, each creating a partBatch via insert()/inserter, or async_insert = 1
Schema mismatch error on fetchStruct field type doesn't match column typeFix the struct; validation (0.15+) is telling you exactly which field
NotEnoughData with no detailsSame mismatch, but validation disabledRe-enable validation to see the real error
First Cloud query takes secondsIdle-suspended instance wakingExpected on Cloud; keep-alive pings if latency matters
TLS feature errors at compile timeNo TLS feature selected for an https:// URLAdd native-tls or rustls-tls feature

Verifying your data

Whichever driver you use, at some point you'll want to eyeball what actually landed in ClickHouse without writing another fetch loop. Mako connects to ClickHouse (and 8 other databases) with AI-powered autocomplete, so you can sanity-check counts and spot-check rows while your Rust service does the writing. Try it free at mako.ai.

Mako — open source

Skip the terminal. Use Mako.

Connect your database, write queries with AI assistance, and import/export data in clicks. Free to start.