How to Connect to ClickHouse from Rust
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
| Library | Protocol / port | Status | Version (as of July 2026) |
|---|---|---|---|
clickhouse | HTTP, 8123 (8443 TLS) | Official, actively maintained by ClickHouse Inc | 0.15.1 |
klickhouse | Native TCP, 9000 | Community, maintained | 0.15.3 |
clickhouse-rs | Native TCP, 9000 | Dormant -- last stable release 2023 | 0.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
clickhousecrate uses. - 9000 -- native TCP protocol. This is what
klickhouseandclickhouse-rsuse (and theclickhouse-clientCLI).
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:
Clientwraps a connection pool internally -- create it once and clone it cheaply; clones share the pool.- Parameters bind with
?via.bind(). There is also a?fieldsplaceholder that expands to the field names of yourRowstruct, which keepsSELECTlists in sync with the struct. - Rows are plain structs deriving
Rowplus serde'sDeserialize/Serialize.rename,skip_serializingand 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 abortedRows 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
| Error | Cause | Fix |
|---|---|---|
| Connection reset / garbled response | HTTP client pointed at 9000, or native client at 8123 | Official crate → 8123/8443; klickhouse → 9000/9440 |
TOO_MANY_PARTS | Row-at-a-time inserts, each creating a part | Batch via insert()/inserter, or async_insert = 1 |
| Schema mismatch error on fetch | Struct field type doesn't match column type | Fix the struct; validation (0.15+) is telling you exactly which field |
NotEnoughData with no details | Same mismatch, but validation disabled | Re-enable validation to see the real error |
| First Cloud query takes seconds | Idle-suspended instance waking | Expected on Cloud; keep-alive pings if latency matters |
| TLS feature errors at compile time | No TLS feature selected for an https:// URL | Add 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.
Skip the terminal. Use Mako.
Connect your database, write queries with AI assistance, and import/export data in clicks. Free to start.