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.
| Spec | ClickHouse HTTP interface, formats, settings |
| Transport | HTTP/1.1; port 8123 plaintext and 8443 TLS by convention |
| In Loams | fabric/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 |
| Decisions | D45, D347, D348, D761, D766, D767, D772, D774, D777; HS1 ruling R-ACL.1 and the protocol stance of 2026-10-13 |
| Status | Experimental 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 aGET, which ClickHouse treats as read-only; - as the body of a
POST; - as
?query=on aPOSTwith the body as the statement's data, which is how inserts are streamed:?query=INSERT INTO t FORMAT CSVWithNamesand 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 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,defaultholds no source grants at all, user connections runreadonly = 2andallow_ddl = 0, and a settings profile pins every setting outside the front's allow-list. ClickHouse itself refusesurl()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 FUNCTIONand host-fingerprinting functions. Refusals are344 SUPPORT_IS_DISABLED. - L3, the worker's OS sandbox. chDB runs only in
loams-house-workerprocesses, never in the front (D761). In the defaultpodsmode a worker gets Landlock (read its code, write its own directory, TCP only to its read proxy) and seccomp (noexecve, no new processes, no namespaces);netnsadds 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 throughhouse-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-Progresslines 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 streamingExecuteQuerycarries it. - No
/play,/dashboard,/replicas_statusor predefined handlers./metricsbelongs toloams-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 QUERYkills the worker. chDB cannot interrupt a running statement (we measured a cancel taking 274 seconds on a largecount()), so cancellation is killing the process, which always works.
Gotchas
- Check the end of the body. A
200with 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. Usewait_end_of_query=1when you need the status to be the truth. GETis read-only. A dashboard that sends DDL overGETgets164. UsePOST.- One credential source per request. A client that sets both Basic auth and
X-ClickHouse-Useris refused, not resolved. compress=1is 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_ENDPOINTthe engine answershouse_not_configuredforloams.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.