Rand Stats

ClickHouse

zef:rdeytoth

ClickHouse

A type-aware ClickHouse client for Raku, speaking the HTTP interface — with live query progress, cancellation, streaming results, and lossless handling of the types that usually go wrong.

Depends only on JSON::Fast.

use ClickHouse;

my $ch = ClickHouse.new(host => 'localhost', database => 'metrics');

my $r = $ch.query('SELECT country, count() AS hits FROM events GROUP BY country');
say "{.<country>}: {.<hits>}" for $r.hashes;

Installing

zef install ClickHouse

Requires ClickHouse 23.6 or newer for the default session_timezone=UTC behaviour; pass timezone => '' to work with older servers.

History

This module was created Erik Zoltan and Randy Dey-Toth in August 2026 using Claude Fable.

Randy had been accessing ClickHouse in Raku using the Perl 5 ClickHouse driver via the Inline::Perl5 Raku module. It worked, but had limitations, such as not having parameter binding.

The ClickHouse HTTP interface was well documented, and it seemed Raku should have it's own ClickHouse driver, rather than relying on a Perl5 one, but who has the time for such exercises, when a workable solution was already in hand?

Claude, that's who!

Randy added two wrapper methods to the code, "select", and "do", method names used by the Perl5 ClickHouse driver. That way, all his existing code worked without further modification by merely removing the ":from'Perl5'" adverb from the "use ClickHouse" statement at the top of the code.

Outside of this History section, and those two wrapper methods, written by Randy, everything in this distribution was written by Claude Fable as a "work-for-hire", as requested by Randy and directed by Erik.

Progress

clickhouse-client shows a progress bar while a query runs. So does this, and for the same reason: ClickHouse reports cumulative counters roughly every 100 ms. Pass a ClickHouse::ProgressBar and you get one:

use ClickHouse;
use ClickHouse::Progress;

$ch.query('SELECT count() FROM huge_table WHERE ...',
          on-progress => ClickHouse::ProgressBar.new);
  ━━━━━━━━━━━━━━━━━━             62.5%  1.25B rows  10.00 GB  1.45B rows/s

The bar writes to stderr, so it never contaminates piped output, and it stays silent when stderr is not a terminal — cron jobs and test runs are not filled with escape codes.

For anything else, handle the snapshots yourself:

$ch.query($sql, on-progress => -> $p {
    note sprintf '%.1f%% — %d of %d rows, %.1fs',
        ($p.fraction // 0) * 100, $p.read-rows, $p.total-rows, $p.elapsed;
});

A ClickHouse::Progress carries read-rows, read-bytes, total-rows, written-rows, elapsed-ns and memory-usage, plus derived fraction, elapsed and rows-per-second. fraction is Nil when the server cannot estimate a total, which happens more often than you would like — handle it.

execute() and insert() report progress too. They have no result body to interleave records into, so those counters arrive as repeated X-ClickHouse-Progress headers instead; from your side it looks the same.

Cancelling a query

Call .cancel on a progress snapshot. The driver issues KILL QUERY on a separate connection and throws, with whatever rows had already arrived:

my $result = try $ch.query('SELECT ... FROM enormous', on-progress => -> $p {
    $p.cancel if $p.elapsed > 30;
});
if $! ~~ X::ClickHouse::Cancelled {
    say "gave up after {$!.partial.elems} rows (query_id {$!.query-id})";
}

Parameters

Placeholders are bound server-side. There is no string interpolation anywhere in this driver, so there is nothing for a quote to escape out of:

$ch.query('SELECT * FROM users WHERE id = {id:UInt64} AND name = {n:String}',
          params => { id => 42, n => "o'brien" });

Types

Values are coerced using the type strings the server sends with the data, not guessed from their JSON shape:

ClickHouseRaku
Int8 … Int256, UInt8 … UInt256Int (arbitrary precision)
Float32, Float64Num
Decimal(P, S)FatRat (exact)
BoolBool
Date, Date32Date
DateTime, DateTime64DateTime (UTC)
Nullable(T)Nil, or the coerced T
Array(T)Array of coerced T
LowCardinality(T)as T
String, UUID, Enum, IPv4/IPv6, Map, Tuple, JSONStr or the decoded JSON structure

Two decisions worth knowing about:

Decimals are exact. The driver sets output_format_json_quote_decimals=1 so they arrive as strings, and parses them with FatRat. Unquoted, a Decimal(38, 26) is already mangled by the JSON parser before any Raku code can intervene; and .Rat caps its denominator at 64 bits, which goes silently wrong past about 19 significant digits.

A column with its own timezone comes back as a string. For DateTime('Europe/London') the server renders values in that zone regardless of the session setting, and core Raku has no timezone database to map the name to an offset. Returning a DateTime mislabelled as UTC would be worse than returning the text, so you get the text.

Inserting

$ch.insert('events', [
    [1, 'click', DateTime.now, 0.25],
    [2, 'view',  DateTime.now, 1/3],
], columns => <id kind at weight>);

Rows are encoded by the same code that encodes query parameters, and sent in batches (batch-size, default 10 000) so a large load does not have to fit in memory as one string.

Large result sets

query() materialises every row. When that will not fit, use each(), which hands you rows as they arrive and keeps memory flat:

my $summary = $ch.each('SELECT id, payload FROM enormous', -> @row {
    process(@row);
});
say "{$summary.summary.read-rows} rows read";

Errors

ClassMeaning
X::ClickHousethe server rejected the query; .code is the ClickHouse exception code
X::ClickHouse::Connectionnever got a verdict — DNS, refused, reset, TLS, truncated
X::ClickHouse::Timeoutthe client-side timeout elapsed; .query-id says what to kill
X::ClickHouse::Cancelledan on-progress handler called .cancel; .partial holds the rows so far
X::ClickHouse::Usagethe driver was called wrongly; nothing was sent

A query that fails after ClickHouse has begun streaming — a memory limit hit half way through a scan is the usual way — cannot be reported as an HTTP error, because the 200 has already gone out. The server appends a tagged exception envelope to the body instead. This driver detects that envelope and raises the real X::ClickHouse, with the real code, rather than letting a JSON parse error surface in its place.

Connection options

ClickHouse.new(
    host     => 'localhost',
    port     => 8123,
    user     => 'default',
    password => '',
    database => 'default',
    scheme   => 'http',        # https needs IO::Socket::Async::SSL
    timezone => 'UTC',         # sent as session_timezone; '' to disable
    readonly => False,         # True sends readonly=2 on every request
    timeout  => 30,            # seconds; undefined means no client-side bound
    settings => { max_threads => 8 },
    on-progress => ClickHouse::ProgressBar.new,   # default for every call
);

For https, tls-config is passed straight through to IO::Socket::Async::SSL.connect:

ClickHouse.new(
    host   => 'clickhouse.example.com', port => 8443, scheme => 'https',
    user   => 'reporting', password => %*ENV<CH_PASSWORD>,
    tls-config => { ca-file => '/etc/ssl/certs/ca.pem' },
);

A ClickHouse handle holds one connection and runs one query at a time. Give each thread its own handle rather than sharing one.

readonly => True gives you a handle that cannot mutate data — useful for the connection your reporting code holds.

Why its own HTTP client

The driver needs the response body delivered line by line while the query is still running, so {"progress":...} records can drive a bar. That rules out any client whose only interface is "await the whole body". Speaking the one POST that ClickHouse needs is cheaper than depending on a stack that speaks all of HTTP — and it keeps the dependency list at JSON::Fast, so the module installs on Rakudo versions that a larger stack no longer supports.

TLS is the exception: https requires IO::Socket::Async::SSL, loaded only if you ask for it.

Not yet

Testing

Unit tests need nothing. Integration tests need a server:

zef test .
CLICKHOUSE_TEST_HOST=localhost zef test .

CLICKHOUSE_TEST_PORT, _USER, _PASSWORD and _DATABASE are honoured too.

License

Artistic License 2.0. See LICENSE.