Skip to main content
Before the official ClickHouse provider existed, most Airflow users connected to ClickHouse through the community package airflow-clickhouse-plugin. This guide walks through moving an existing deployment to apache-airflow-providers-clickhousedb. For how the provider itself works, see Connect Apache Airflow to ClickHouse.

Why a native provider?

Providers are how Airflow integrates with third party systems. They are released, tested and documented together with the rest of the Airflow ecosystem, and this one is maintained by the Airflow community together with the ClickHouse team. It is built on clickhouse-connect, the Python client that ClickHouse itself develops and supports, rather than on a community maintained driver. New server features and fixes therefore reach Airflow users through a supported path. Moving to the provider gives you a package with an official home, the standard common.sql operators and sensors, and a connection type that shows up in the Airflow UI like every other database. The two packages differ in more than import paths. The plugin talks to ClickHouse over the native TCP protocol using clickhouse-driver. The provider talks over HTTP(S) using clickhouse-connect and plugs into the generic common.sql operators instead of shipping ClickHouse specific ones.
Read the whole guide once before changing anything. The connection change in particular affects every DAG at the same time.

At a glance

Step 1: Check prerequisites and install

The provider requires Airflow 2.11 or newer and apache-airflow-providers-common-sql 1.32.0 or newer. Upgrade Airflow first if you are on an older release.
The two packages live in different Python namespaces, so they can be installed side by side while you migrate DAG by DAG. Remove the plugin once nothing imports it any more:

Step 2: Update connections

This is the step that breaks things if you skip it. Every existing ClickHouse connection points at the native port, and the provider needs the HTTP port. If ClickHouse sits behind a firewall or a load balancer, make sure the HTTP port is reachable from the workers before switching. Check that the HTTP interface is enabled on the server (http_port or https_port in the server configuration). ClickHouse Cloud exposes HTTPS on 8443 only. Connections stored as URIs (clickhouse://user:pass@host:9000/db?secure=true) already have the clickhouse connection type because Airflow derives it from the URI scheme. Only the port and the extra keys change for them; query-string values are parsed as JSON, so secure=true stays a boolean.

Connection extras

The plugin passed every key in extra straight into clickhouse_driver.Client, so connections may carry any clickhouse-driver keyword argument. The provider only reads a fixed set of keys and forwards anything else through client_kwargs. Translate as follows: Before:
After:
Verify each migrated connection before touching DAGs:

Step 3: Replace imports

The ClickHouse-prefixed common.sql wrappers existed only to inject the plugin’s hook. The provider registers the clickhouse connection type, so the unprefixed common.sql classes resolve the hook from the connection on their own. If you used those wrappers, the migration is usually the import line, dropping the ClickHouse prefix, and passing conn_id explicitly (see step 7).

Step 4: ClickHouseOperator to SQLExecuteQueryOperator

Before:
After:

Multi-statement results

The plugin pushed the result of the last statement to XCom. SQLExecuteQueryOperator returns one result per statement when sql is a list, so the example above pushes [[], [(12345.0,)]] where the plugin pushed [(12345.0,)]. Pick one of these:
  • Change the downstream xcom_pull to take the last element.
  • Pass the statements as a single string separated by ; and set split_statements=True. With the default return_last=True the operator then pushes only the last statement’s rows, matching the plugin.
A ClickHouseOperator that inserted a list of rows through parameters becomes an SQLInsertRowsOperator:
Always pass columns; without it the operator looks the table up through SQLAlchemy.

Keeping column types

with_column_types=True returned (rows, [(name, type), ...]). Reproduce it with a handler; the clickhouse-connect cursor reports ClickHouse type names in cursor.description:

Step 5: ClickHouseHook.execute to DbApiHook methods

The plugin’s hook exposed one method, execute, mirroring clickhouse_driver.Client.execute. The provider’s hook is a DbApiHook, so it gets the standard methods that every other SQL provider has: run, get_records, get_first, get_pandas_df, get_df, insert_rows and test_connection. The clickhouse_conn_id and database constructor arguments are unchanged. fetch_all_handler and the other handlers are imported from airflow.providers.common.sql.hooks.handlers. The most common hook idiom in plugin era code is the bulk insert. It cannot be a plain run call, because the DB-API cursor would try to format the rows into the SQL string. Use the native insert instead: Before:
After:
bulk_insert_rows requires column_names. batch_size is optional and bounds memory on very large inputs. The generic insert_rows(table, rows, target_fields=[...], executemany=True) also ends in a native insert, but only with executemany=True; the default sends one HTTP request per row. For anything the DB-API surface does not cover, get_client() returns the raw clickhouse-connect client configured from the Airflow connection. It is the replacement for every clickhouse-driver specific argument the plugin exposed:

Step 6: ClickHouseSensor to SqlSensor

This is the one replacement where the callable’s input changes. Because the plugin handed over the full result set, working sensor code indexes into it. Remove the indexing when you migrate: Before:
After:
If your callable needs the whole row, pass selector=lambda row: row. If it needs all rows, write the check in SQL so the query returns a single boolean or count.

Step 7: The common.sql wrapper family

Code that used ClickHouseSQLExecuteQueryOperator, ClickHouseSqlSensor and the other ClickHouse-prefixed wrappers needs the least work:
  • Change the import to the common.sql module and drop the ClickHouse prefix from the class name.
  • Pass conn_id explicitly. The wrappers treated a missing or None conn_id as clickhouse_default; the common.sql classes have no default and fail without one. default_args={"conn_id": "clickhouse_default"} covers a whole DAG.
  • database= on the operators and hook_params={"schema": ...} on the sensor keep working; the provider’s hook treats schema as an alias of database.
  • ClickHouseDbApiHook becomes ClickHouseHook. Its schema constructor argument is still accepted as an alias; database is the ClickHouse native spelling and takes precedence when both are given.
  • The connection still needs the port and extras changes from step 2. The wrappers used the native protocol too.

Behavior differences to review

Even after the code compiles, a few things behave differently at run time. XCom value of INSERT tasks. The plugin pushed whatever clickhouse-driver returned, which for a VALUES insert with parameters was the inserted row count. The provider pushes an empty result set for statements that return no rows. Downstream tasks that read the row count from XCom must obtain it another way, for example with a follow-up SELECT count(). Session settings versus SET statements. Both packages run a multi-statement list over a single connection, and clickhouse-connect creates a session per client by default, so a SET statement early in the list should still apply to later statements. Prefer session_settings anyway. It is explicit and templated, and it works the same whether or not the server or an intermediate proxy keeps the session. Confirm behavior in your environment if your DAGs depend on SET. Exceptions. Errors are now clickhouse_connect.driver.exceptions.DatabaseError, OperationalError or ProgrammingError instead of clickhouse_driver.errors.ServerException and NetworkError. Update except clauses, on_failure_callback code and retry logic that inspects exception types. Compression. clickhouse-driver left compression off unless compression was set; clickhouse-connect enables HTTP response compression by default and negotiates the algorithm with the server. Set "compress": false in the connection extra to restore the old behavior. The clickhouse-cityhash package the plugin needed for native compression is no longer required; lz4 stays installed as a clickhouse-connect dependency. Type mapping. Both drivers return native Python types, but they are different code bases. Review tasks that depend on exact types for DateTime64 with time zones, Decimal, UUID, Nullable columns and nested Array or Map values, especially where the result is pushed to XCom and consumed downstream. Query identification in system.query_log. Queries now arrive through the HTTP interface, so they show up with interface = 2 instead of 1, and the http_user_agent column of system.query_log carries the Airflow and provider versions plus the client_name extra if set. Any monitoring that filtered on the native protocol or on the clickhouse-driver client name needs updating. A SELECT that returns no rows produces a second entry: the DB-API cursor runs SELECT * FROM (...) LIMIT 0 to recover the column metadata. Timeouts over HTTP. send_receive_timeout is now the HTTP read timeout, and any proxy or load balancer between the workers and ClickHouse applies its own idle timeout to the request. Statements that ran for many minutes over the native protocol may need those limits raised. Connection handling. The hook creates a clickhouse-connect client per run or get_client call, mirroring how the plugin opened a fresh native connection per execute. Clients share a process wide HTTP connection pool, so calling close() on a client from get_client() is good hygiene rather than a requirement; the pool is released when the task process exits. The client is a context manager, so with hook.get_client() as client: is the tidiest form.

Checklist

  1. Airflow is 2.11 or newer.
  2. HTTP port reachable from workers; TLS certificates valid for the HTTP endpoint.
  3. Every ClickHouse connection: type clickhouse, port 8123 or 8443, extras translated per step 2, verified with airflow connections test.
  4. Imports replaced per step 3; ClickHouse prefixes dropped from common.sql wrappers.
  5. clickhouse_conn_id renamed to conn_id on operators and sensors, and conn_id set on every task that relied on the plugin’s default.
  6. settings= moved into hook_params={"session_settings": ...}.
  7. hook.execute("INSERT ... VALUES", rows) replaced with bulk_insert_rows; ClickHouseOperator(parameters=rows) replaced with SQLInsertRowsOperator.
  8. Sensor callables adjusted from result[0][0] to the bare cell value.
  9. Uses of with_column_types, external_tables, columnar, query_id and types_check rewritten with a handler or get_client().
  10. Downstream consumers of XComs from multi-statement and INSERT tasks reviewed.
  11. Code that catches clickhouse_driver exceptions updated.
  12. airflow-clickhouse-plugin and clickhouse-driver uninstalled.

Using an AI coding assistant

The mapping above is deliberately mechanical so that a coding assistant can apply it to a DAG repository. A prompt that has worked well:
Review the diff. The connection changes, the sensor semantics and the XCom consumers are the places where automated rewrites go wrong.
Last modified on September 17, 2026