This guide walks through adding ClickHouse as a data source in a Lotus-powered application.

Prerequisites

  • An application with Lotus v1.0 installed and configured
  • A running ClickHouse instance (local, Docker, or cloud-hosted)
  • Elixir 1.18 or later

Step 1: Add the dependency

defp deps do
  [
    {:lotus, "~> 1.0"},
    {:lotus_clickhouse, "~> 0.1"}
  ]
end
mix deps.get

This pulls in ecto_ch (the Ecto adapter for ClickHouse) and ch (the underlying HTTP driver) automatically.

Step 2: Define a ClickHouse Ecto repo

# lib/my_app/clickhouse_repo.ex
defmodule MyApp.ClickHouseRepo do
  use Ecto.Repo,
    otp_app: :my_app,
    adapter: Ecto.Adapters.ClickHouse
end

Nothing else goes on this module. The adapter reads repo.config() for the database name and migration source, and calls repo.query/3 for everything else.

Step 3: Configure the repo

# config/config.exs (or config/runtime.exs for production)
config :my_app, MyApp.ClickHouseRepo,
  hostname: "localhost",
  port: 8123,
  scheme: "http",
  database: "default",
  username: "default",
  password: "",
  pool_size: 5

Connection options

These are ch's options, passed straight through by ecto_ch:

OptionDefaultDescription
:hostname"localhost"ClickHouse server hostname
:port8123HTTP interface port — note this is the HTTP port, not the native 9000
:scheme"http""http" or "https"
:database"default"Database this source exposes
:usernameClickHouse user; omitted means ClickHouse's own default user
:passwordUser password
:settings[]ClickHouse settings applied to every query, as a keyword list
:timeoutHTTP request/receive timeout in milliseconds
:pool_size1 (DBConnection's default)Connection pool size
:transport_optsTransport options, e.g. TLS settings for https

The :database value matters beyond connectivity. The dialect's default_schemas/1 returns exactly this one database, and builtin_denies/1 uses it to qualify the deny entries for Lotus's own metadata tables. ClickHouse will still resolve a fully qualified other_db.table at execution time, but such a table is outside this source's default schema, so Lotus's visibility rules have to allow it explicitly.

:settings is the place to put server-side guardrails that survive every query — max_execution_time, max_memory_usage, max_result_rows. The adapter does not set them for you, and the dialect's set_statement_timeout/2 is a no-op, so the :timeout option bounds only the HTTP client.

Step 4: Register the adapter with Lotus

# config/config.exs
config :lotus,
  storage_repo: MyApp.Repo,          # where Lotus stores queries and dashboards
  default_source: "postgres",
  source_adapters: [Lotus.Source.Adapters.ClickHouse],
  data_sources: %{
    "postgres"   => MyApp.Repo,
    "clickhouse" => MyApp.ClickHouseRepo
  }

:source_adapters is what makes the source resolvable. Core probes each listed adapter's can_handle?/1, and the one generated by use Lotus.Source.Adapters.Ecto claims any repo whose __adapter__/0 equals the dialect's ecto_adapter/0Ecto.Adapters.ClickHouse. Because the repo module identifies itself, the :data_sources entry stays a bare module; there is no map form to fill in.

Leave out :source_adapters and the entry will not resolve, however correct the repo is.

Trusting the adapter's AI context

The dialect ships ClickHouse syntax notes and error-pattern hints for the AI pipeline, but core strips an adapter's context down to :language unless the adapter is allowlisted:

config :lotus,
  trusted_source_adapters: [Lotus.Source.Adapters.ClickHouse]

Without this, AI-generated queries for this source get core's generic SQL guidance, and the Code: 60 / Code: 47 / READONLY self-correction hints never reach the model.

Step 5: Start the repo

# lib/my_app/application.ex
def start(_type, _args) do
  children = [
    MyApp.Repo,
    MyApp.ClickHouseRepo,
    MyAppWeb.Endpoint
  ]

  Supervisor.start_link(children, strategy: :one_for_one, name: MyApp.Supervisor)
end

Lotus itself is an OTP application and starts its own supervisor, so only the repos go here.

Step 6: Verify

Start the app and run a statement against the new source:

iex> Lotus.run_statement("SELECT version()", [], repo: "clickhouse")
{:ok, %Lotus.Result{rows: [["24.8.x"]], ...}}

Or open the Lotus UI and pick the clickhouse source. If the source selector shows it but queries fail at preflight, check that the tables you are querying are allowed by your visibility rules — this adapter does enforce them, using EXPLAIN AST to find out which tables a statement touches.

Multiple ClickHouse instances

One repo per instance, all claimed by the same registered adapter:

config :lotus,
  source_adapters: [Lotus.Source.Adapters.ClickHouse],
  data_sources: %{
    "postgres"  => MyApp.Repo,
    "analytics" => MyApp.AnalyticsRepo,   # ClickHouse analytics cluster
    "logs"      => MyApp.LogsRepo         # ClickHouse logs cluster
  }

Each repo carries its own credentials and its own :database, and therefore its own default schema and deny list.

ClickHouse Cloud

For ClickHouse Cloud, or any TLS-enabled instance, use the HTTPS port:

config :my_app, MyApp.ClickHouseRepo,
  hostname: "abc123.clickhouse.cloud",
  port: 8443,
  scheme: "https",
  database: "default",
  username: "default",
  password: System.get_env("CLICKHOUSE_PASSWORD"),
  pool_size: 5

Everything in this package goes over the HTTP interface, so a cloud instance needs nothing else. Read-only enforcement is a per-query readonly=1 setting, which a cloud user can hold; it does not require any privilege of its own.

Docker for local development

A minimal service for your own project:

services:
  clickhouse:
    image: clickhouse/clickhouse-server:24.8
    ports:
      - "8123:8123"
    environment:
      CLICKHOUSE_DB: default
      CLICKHOUSE_USER: default
      CLICKHOUSE_PASSWORD: clickhouse
      CLICKHOUSE_DEFAULT_ACCESS_MANAGEMENT: 1
docker compose up -d

This package's own docker-compose.yml deliberately publishes different host ports — 9123 for HTTP and 9100 for the native protocol — so that running the adapter's test suite does not collide with a ClickHouse already listening on 8123. If you clone this repo to work on it, that is what config/test.exs expects; see the Development section of the README.

Next steps