Loams v0.1.0 is out: run the whole stack yourself with Docker Compose. See the roadmap

Blog/The protocols Loams speaks

The ClickHouse HTTP interface: how Loams House answers curl

ClickHouse's HTTP interface is the simplest database protocol in wide use, a query in a POST body and the answer in the format you ask for. How it works, why Loams House speaks it before ClickHouse's native protocol, and what the House accepts, refuses and does differently.

The ClickHouse HTTP interface: how Loams House answers curl
On this page
  1. The protocol
  2. Why Loams speaks it
  3. How the House implements it
  4. Deviations we chose
  5. Gotchas

ClickHouse has several front doors: a native TCP protocol, MySQL and Postgres wire emulation, gRPC, and an HTTP interface. The HTTP one is the plainest. You send SQL in a request, you get the result back as a body in whatever format you named, and errors come back as a status code and a header. There is no handshake beyond HTTP's own, no framing beyond HTTP's own, and curl is a complete client.

Loams House, our ClickHouse-compatible analytics service on Iceberg tables in your bucket, speaks this interface first. This post is about the protocol and what the House does with it. The House itself, chDB and the sandbox around it have their own Learn page.

SpecClickHouse HTTP interface, formats, settings
TransportHTTP/1.1; port 8123 plaintext and 8443 TLS by convention
In Loamsfabric/crates/loams-house (http.rs, auth.rs, compress.rs, session.rs, classify.rs, deny.rs), the loams-fabric binary's house role, loams-house-worker running chDB 26.9.0
DecisionsD45, D347, D348, D761, D766, D767, D772, D774, D777; HS1 ruling R-ACL.1 and the protocol stance of 2026-10-13
StatusExperimental served by --profile house in Loams 0.1.0 on its own tailnet name; the engine reaches it once LOAMS_HOUSE_ENDPOINT is set

The protocol

A request. GET / and GET /ping answer Ok. for health checks. Anything else is a query. It can travel in three ways:

  • as ?query= on a GET, which ClickHouse treats as read-only;
  • as the body of a POST;
  • as ?query= on a POST with the body as the statement's data, which is how inserts are streamed: ?query=INSERT INTO t FORMAT CSVWithNames and the CSV in the body.

Formats. The output format is the statement's FORMAT clause, or the default_format parameter, or TabSeparated. The same server can answer JSONEachRow to a web app, Parquet or Arrow to a data tool, RowBinary to the Rust client and Native to the Python one. Format choice is the client's whole serialization strategy, and it is why the HTTP interface works for so many tools without a driver.

Settings as parameters. Any URL parameter that is not one of the interface's own (query, database, default_format, query_id, session_id, user, password, compress, enable_http_compression, wait_end_of_query and a few more) is a query setting: ?max_result_rows=1000&max_execution_time=5. Parameterized queries use param_<name> parameters for {name:Type} placeholders.

Authentication. Three ways, one at a time: X-ClickHouse-User and X-ClickHouse-Key headers, HTTP Basic, or user and password parameters. Without any, the user is default with an empty password.

Sessions. HTTP is stateless, so ClickHouse adds session_id. Requests with the same id share SET settings, the current database and temporary tables, until session_timeout passes. A session runs one statement at a time.

Compression. Two different things share the word. enable_http_compression=1 with Accept-Encoding asks for standard HTTP content coding of the response, and a request body may carry Content-Encoding. Separately, compress=1 and decompress=1 use ClickHouse's own compression format, which is not a standard HTTP encoding. Each block is a 16-byte CityHash128 checksum, one method byte (0x82 for LZ4, 0x90 for ZSTD), a 4-byte compressed size that includes the 9-byte header, and a 4-byte uncompressed size, all little-endian (CompressionInfo.h). LZ4 is the default codec.

The response. Response headers carry X-ClickHouse-Query-Id, X-ClickHouse-Format, X-ClickHouse-Timezone, X-ClickHouse-Server-Display-Name and X-ClickHouse-Summary (rows and bytes read and written, as JSON). With send_progress_in_http_headers=1, ClickHouse keeps the header block open and writes X-ClickHouse-Progress lines while the query runs. An error before the body starts is a 4xx or 5xx with X-ClickHouse-Exception-Code and the message as the body. An error after the body has started cannot change the status any more, so the exception text is appended to the body. wait_end_of_query=1 buffers the whole result on the server to avoid exactly that.

That last point is the protocol's one real sharp edge: a 200 does not mean success unless the body arrived complete.

Why Loams speaks it

We first said no. D45 dropped every ClickHouse surface: the HTTP interface, the dialect, function compatibility and MergeTree DDL. Analytics would be Iceberg tables, queried through Flight SQL and the native API, and by ClickHouse itself as an Iceberg reader.

D347 reversed that, for the House only. The argument was practical: teams already have ClickHouse queries, dashboards and drivers, and apps such as OpenPanel run their ClickHouse SQL unchanged if something answers it. So the House declares a ClickHouse surface, versioned (chsurface-1, now chsurface-2.0 under D767), with a strict allowlist of deviations (D348). "Compatible" means 100% of the declared surface's tests pass, not that every ClickHouse feature exists. The engine's own SQL and data stay as they were; the House is a separate binary.

Why HTTP before native TCP? HTTP is what most ClickHouse drivers and BI tools already support (the Rust client uses RowBinary over HTTP, clickhouse-connect uses HTTP, JDBC v2 uses HTTP), it passes through every proxy and sidecar, and it reuses a mature HTTP stack instead of a hand-written codec. D767 proposed pulling the native protocol forward, because clickhouse-client and several drivers speak only native. Our protocol decision of 2026-10-13 settled the order: the HTTP interface is the compatibility interface, Arrow Flight SQL is the recommended fast open protocol (see part 3), and native TCP gets built only if there is demand from clickhouse-client and native-driver users.

How the House implements it

The front authenticates, classifies and admits; only the sealed worker runs chDB, and it reaches data only through house-cache. With send_progress_in_http_headers=1 the House sends one X-ClickHouse-Progress line with the head, not a running series.

The transport. http.rs serves HTTP/1.1 on hyper. hyper owns framing: header limits (431), URI length (414), conflicting Content-Lengths (400), and a Content-Length together with chunked encoding read as chunked with the connection closed afterwards, so nothing can be smuggled behind a request. Expect: 100-continue is answered only when the body is first read, which is after authentication. Connections are capped, idle reads and writes time out, and a connection ends with a lingering close.

Credentials. auth.rs accepts exactly one source: headers, Basic, parameters, or a Bearer key. Mixing sources is refused with code 516, as ClickHouse refuses it. An unknown user and a wrong password get the same answer and the same work (a digest is compared either way), so neither the text nor the timing tells them apart. In 0.1.0 the user is default and the password is a key the House generates on first start. The production design (D772) maps ClickHouse credentials onto Loams API keys and tokens, with house:read, house:write, house:ddl and house:admin scopes.

Read-only before anything runs. A GET, a HEAD or a read-only user can only read; anything else is 164 READONLY before a worker is even asked. An INSERT takes a worker only once its data starts arriving, so a slow client cannot hold one idle.

Three security layers. The House runs user SQL in chDB, which is ClickHouse as a library, and a ClickHouse statement can reach a surprising amount of the world (url(), s3(), file(), remote(), executable table functions, dictionaries with HTTP sources). So:

  • chDB's own access control comes first (ruling R-ACL.1). We measured that every chDB connection authenticates as default, so a second login user cannot exist. Instead, default holds no source grants at all, user connections run readonly = 2 and allow_ddl = 0, and a settings profile pins every setting outside the front's allow-list. ClickHouse itself refuses url() and friends.
  • L1, the statement checks, stay as a second line: a deny list over chDB's analysed query tree, which catches table functions in subqueries, views, CTEs and joins, plus INTO OUTFILE, FROM INFILE, CREATE FUNCTION and host-fingerprinting functions. Refusals are 344 SUPPORT_IS_DISABLED.
  • L3, the worker's OS sandbox. chDB runs only in loams-house-worker processes, never in the front (D761). In the default pods mode a worker gets Landlock (read its code, write its own directory, TCP only to its read proxy) and seccomp (no execve, no new processes, no namespaces); netns adds a network namespace with only loopback. A worker whose sandbox cannot be applied refuses to boot. Workers hold no bucket credentials and read data only through house-cache, a loopback S3 read proxy in the front (D766).

Responses. Output is held until buffer_size (1 MiB by default, clamped between 1 byte and 16 MiB) has accumulated or the statement ends. So a statement that fails early still gets its own status and X-ClickHouse-Exception-Code, and a small result's X-ClickHouse-Summary is final. Past that, the head goes out and the body streams; a later failure appends the exception text and closes the connection without the terminating chunk, so no client mistakes it for a complete result. wait_end_of_query=1 spools the whole result, in memory and then in an unlinked temporary file, within a budget.

Sessions. session.rs keys a session by the user and its session_id, so one user cannot attach to another's session by guessing an id. A session holds its settings and current database in the front, so it survives worker recycling; once it creates a temporary table it is pinned to one worker, whose chDB connection holds the table. session_check=1 on an unknown session is 372, a busy session 373, the timeout defaults to 60 seconds with a 3,600-second ceiling, and a front holds at most 1,024 live sessions.

Compression. compress.rs speaks gzip, deflate (zlib-wrapped, as ClickHouse does) and zstd, in both directions, streaming. ClickHouse's own compress=1 LZ4 framing is not served yet.

What it runs. In Loams 0.1.0: queries over the allowed table functions (numbers, values, generateRandom and the like), lake-table DDL and reads, the allowed system tables, and, on lake tables, INSERT in every declared format, CREATE TABLE … AS SELECT, TRUNCATE and OPTIMIZE TABLE. Lake tables are Iceberg (Parquet) through Lakekeeper, never MergeTree (D762).

Where it listens. The House binds 127.0.0.1:8123 and refuses a non-loopback plaintext address; the TLS listener is 8443. In the 0.1.0 compose stack it sits on a network of its own and reaches your devices only through its own Tailscale sidecar, as https://loams-house.<tailnet>.ts.net:

curl -u "default:$PASSWORD" https://loams-house.<tailnet>.ts.net/ \
  --data-binary 'SELECT version()'

The same listener also serves loams.house.v1, the House's Connect API, on paths under /loams.house.v1.; the engine's main port proxies those paths for the console. Because the listener is HTTP/1.1, Connect and gRPC-Web work there and gRPC proper does not (part 6 explains the difference).

Tests and clients. The documented client in 0.1.0 is curl and any HTTP client with Basic auth. The interface's parser has a property-based fuzz test, and the deny corpus lives in conformance/clickhouse/. The driver matrix of D777 (clickhouse-rs, clickhouse-connect, clickhouse-go v2, the JDBC v2 driver, Grafana's and Metabase's ClickHouse plugins) and ClickHouse's own ADBC driver as a second-tier client are planned in the House plan, not yet run in CI. We will not claim a driver works until its suite passes.

Deviations we chose

  • One progress header, not a stream of them. ClickHouse writes X-ClickHouse-Progress lines throughout a query by keeping its header block open. hyper writes a head in one piece, so the House sends one progress line, the counters at the moment the head goes out. This is a recorded surface deviation. For live progress, loams.house.v1's streaming ExecuteQuery carries it.
  • No /play, /dashboard, /replicas_status or predefined handlers. /metrics belongs to loams-fabric's Prometheus endpoint.
  • No multi-statement requests, as in ClickHouse.
  • ClickHouse access DDL answers 48. CREATE USER, GRANT, roles, row policies and quotas are Loams' business, through the console and API (D772).
  • KILL QUERY kills the worker. chDB cannot interrupt a running statement (we measured a cancel taking 274 seconds on a large count()), so cancellation is killing the process, which always works.

Gotchas

  • Check the end of the body. A 200 with a body that ends in an exception and no final chunk is a failure. Clients that read the whole body and check for a clean end handle this; ad-hoc scripts often do not. Use wait_end_of_query=1 when you need the status to be the truth.
  • GET is read-only. A dashboard that sends DDL over GET gets 164. Use POST.
  • One credential source per request. A client that sets both Basic auth and X-ClickHouse-User is refused, not resolved.
  • compress=1 is not HTTP compression. Clients that default to ClickHouse's LZ4 framing need it switched off (or to HTTP compression) until it lands.
  • Temporary tables pin a session to a worker, and pinned workers per namespace are capped. Prefer lake tables or CTEs.
  • The engine and the House are joined only when you ask. Without LOAMS_HOUSE_ENDPOINT the engine answers house_not_configured for loams.house.v1; talk to the House on its own tailnet name. With it set, the engine reaches the House over TLS on a private network.

The House answers ClickHouse clients, but its tables are plain Iceberg: DuckDB, Spark, Trino or a real ClickHouse can read them through the same catalog (Iceberg, iceberg-rust and Lakekeeper covers the format). The HTTP interface is the door; the data never belongs to it.

More from the blog