DuckDB provider
Overview
The DuckDB provider enables Airflow to execute SQL queries against DuckDB databases directly from DAGs. This provider does not include the DuckDB runtime. Instead, it requires the DuckDB CLI component to be installed on every worker node. Under the hood, the provider runs the CLI, submits an SQL query with a set of parameters to the CLI, receives the query results, and forwards them to Airflow.
Requirements
To use the provider, the following are required:
-
Airflow 3.2.1 or later;
-
the DuckDB CLI component installed on every Airflow worker host;
-
a preconfigured Airflow connection to access a DuckDB database.
Architecture
The provider follows the standard Airflow provider architecture and includes the following modules:
-
hooks. Hooks provide an interface for connecting and interacting with DuckDB. -
operators. Operators execute DuckDB queries as Airflow tasks. -
sensors. Sensors wait for a specified condition in DuckDB before triggering downstream processing. -
utils. The module contains utility functions for working with SQL, parsing results, reporting errors, and so on.
DuckDbHook
The provider exposes only one hook — the DuckDbHook class, which encapsulates most of the provider’s runtime logic.
Operators and sensors delegate connection handling, preflight checks, CLI execution, logging, and error reporting to DuckDbHook.
On each run, the hook performs the following:
-
Reads the Airflow connection object and retrieves the database file path and values from the Extra field.
-
Verifies
cli_paramsagainst the banlist and checks that the CLI binary is available. -
Сhecks the local database path.
-
Submits an SQL statement to DuckDB CLI.
-
Forwards query results to Airflow.
-
Writes stdout/stderr to the task logs and raises errors, if any.
The main DuckDbHook methods are:
-
Executes an SQL statement via the DuckDB CLI. After optionally performing
%(name)ssubstitution, the hook writes SQL statement to a temporary file and runs it with the -f flag to allow large SQL submissions. Returns raw stripped stdout as a string. The default output format is JSON. -
Executes an existing .sql file with the -f flag. Executes the file as-is, without any parameter substitution. Does not specify the default output format. Therefore,
output_format="[json|csv]"should be passed explicitly. Raises a configuration error if the specified .sql file does not exist. -
Marks the hook as killed and terminates the active DuckDB process group. Used by operators and sensors when the task is killed.
DuckDbOperator
The provider’s only operator DuckDbOperator runs SQL via DuckDB CLI on a worker node and returns raw CLI stdout, which is then sent to XCom.
The main DuckDbOperator methods are:
-
Runs an SQL statement and returns a raw stdout string from DuckDB CLI. After that, Airflow pushes that string to XCom. Does not return parsed table rows.
With the default
output_format="json", useairflow.providers.arenadata.duckdb.utils.json_output.parse_json_output— it handles optional non-JSON prefixes and requires a JSON list of rows. For example:from airflow.providers.arenadata.duckdb.utils.json_output import parse_json_output raw = context["ti"].xcom_pull(task_ids="select_rows") rows = parse_json_output(raw)Use one statement per task since
_salvage_json()keeps the first JSON array and discards trailing data with a warning. The standardjson.loads()function is safe only when the output contains a valid JSON list without additional content. -
Terminates the active DuckDB process if
execute()has started the hook.
DuckDbSqlSensor
The DuckDbSqlSensor sensor is used to wait until a DuckDB query returns a truthy result.
The sensor executes SQL using a DuckDbHook instance.
The sensor evaluates the first cell of the first returned row and keeps waiting if:
-
the result set is empty, including empty CLI stdout;
-
the first row is missing, is not a mapping, or has no columns;
-
the first cell is Python-falsy (
false, 0,null, or empty string).
|
NOTE
Non-empty strings such as "0" are considered truthy.
|
The main sensor method is poke().
On each method invocation, it runs an SQL statement with output_format="json" using the hook.
Then, it parses the JSON result set and evaluates the first cell of the first row using the Python truthy check (bool(value)), and returns either False (keeps waiting) or True (succeeds).
CLI and JSON errors fail the task by default. Non-zero DuckDB CLI exit codes and invalid JSON raise exceptions. However, there are sensor parameters(defined in BaseSensorOperator) that can change this behavior:
-
silent_fail=True— exceptions inpoke()do not fail the task. An exception is logged, and the sensor waits for the nextpoke(); -
never_fail=True— exceptions inpoke()skip the task; -
soft_fail=True— CLI/JSON errors do not affect waiting/skipping.
|
NOTE
By default, a query against a missing table (for example, Catalog Error: Table … does not exist) fails the task immediately, and the sensor does not continue waiting.
If you need the sensor to wait until data appears, create the table in advance (it can be empty) and poke a condition such as SELECT COUNT(*) > 0 ….
|
The sensor parses only the first JSON array in stdout. Later statements are discarded with a warning in logs. Thus, no statement before the condition should return rows. Keep in mind that:
-
INSTALL,LOAD,ATTACH,SETprint nothing and may precede the condition. -
CREATE SECRETreturns a success row, which will be treated as the first result set. As a result, the sensor succeeds without evaluating the actual condition, and the task is marked as successful with a warning in the logs. -
Each task is a separate CLI process. Operations like
LOAD/ATTACHmust be present in the sensor’s SQL, whereasINSTALL,CREATE PERSISTENT SECRET, andCREATE VIEWcan run once in a precedingDuckDbOperator.
When fail_on_empty=True and the query returns no rows, the sensor raises an AirflowFailException.
With soft_fail=True, the failure causes the poke to be skipped.
When fail_on_empty=False (default), an empty result makes the sensor keep waiting.
Aggregates such as SELECT count(*) … always return one row so they are not an empty result set.
For such queries, check the truthiness of the first cell, and do not rely on fail_on_empty.
Each poke() invocation creates a hook with lock_retry_attempts=0.
Waiting for a locked .duckdb file (or for data to become available) is implemented via Airflow poke_interval and mode="reschedule", not via in-poke CLI lock retries.
Configure a connection
To connect to a DuckDB database, the DuckDB provider needs a preconfigured Airflow connection object. To create one, use Airflow UI. When creating a connection, specify the following parameters.
| Field | Description |
|---|---|
Connection type |
The type of connection used by the provider.
Must be set to |
Database file path |
Absolute path to a .duckdb file on a worker node or |
Additional fields
| Field | Description |
|---|---|
DuckDB binary |
Path to the DuckDB CLI binary used to execute SQL. The default path (/usr/bin/duckdb) points to the executable installed by the DuckDB CLI component of the DuckDB service |
Additional JSON parameters
You can specify additional runtime settings in JSON format in the Extra field.
| Parameter | Description | Default value |
|---|---|---|
timeout |
Timeout in seconds for the DuckDB CLI subprocess to complete |
300 |
readonly |
Opens the specified database in read-only mode. Performs basic checks that the database file exists and is readable |
false |
cli_params |
Additional DuckDB CLI parameters.
The value should be a shell string ( |
— |
lock_retry_attempts |
Defines the number of retries to access the database file in case it is locked |
0 |
{
"timeout": 300,
"readonly": false,
"cli_params": "",
"lock_retry_attempts": 0
}
Locks and retries
DuckDB takes an exclusive lock on the database file when the CLI opens it.
If another process already holds the lock, the CLI fails with an error indicating duckdb_conn_id and db_path.
The lock_retry_attempts property
To manage the locks more flexibly, the lock_retry_attempts connection property (specified in the Extra field) is used.
Its value should be a positive integer that indicates the number of retries in case the database file is locked.
If set to 0, the task fails immediately with a clear lock-related message.
The backoff between attempts is 1, 2, 4, 8, 16 seconds (maximum is 16 seconds).
Total task execution time
Each attempt uses its own timeout.
With retries enabled, the total task execution time can grow up to . Therefore, set the task’s execution_timeout accordingly.
Locks with ATTACH
The lock-retry mechanism assumes that the CLI opens a connection to a single host database.
SQL scripts that include ATTACH 'file.db' operations mid-run can hit the same lock markers after some SQL statements have already been executed.
For SQL with ATTACH commands, use lock_retry_attempts=0 (default value).
Logging
The following table describes logging levels for common log events.
| Logging information | Level |
|---|---|
Full CLI command |
DEBUG |
Run summary ( |
INFO |
stdout |
INFO truncated to |
stderr on success |
INFO truncated to |
stderr on failure |
ERROR |
Lock conflict retry attempt |
WARNING |
CLI banlist
Most of the native DuckDB flags, such as SQL submission (-c, -f, -s), output formats (-json, -csv), and others are managed by the provider.
Specifying these flags in cli_params raises a configuration error.
-
-c -
-s -
-f -
-cmd -
-init -
-json -
-csv -
-readonly -
-bail -
-no-stdin
|
TIP
To specify an output format, use DuckDbOperator(output_format=…).
|
Mask sensitive data
It is recommended to store credentials and sensitive data in the Extra field using special keys masked by Airflow rather than inline in SQL or as operator/hook parameters.
In case of errors, DuckDB typically echoes the SQL statement in stderr, so a secret may appear as plain text in task logs.
Values stored in cli_params with keys shown below are masked with *** in logs.
-
password -
passwd -
secret -
token -
access_key -
access_token -
api_key -
apikey -
private_key -
credential -
credentials -
secret_key -
aws_secret_access_key
For example, the token value will be masked in logs:
{
"cli_params": ["--token", "my-secret-value"]
}
SQL files
SQL files should end with .sql (case-sensitive).
Airflow loads SQL scripts from the DAG bundle (dag.folder and/or template_searchpath), so both the DAG processor and the worker can read them.
Use the lowercase .sql extension and keep script files inside the bundle.
If the specified SQL file is missing, the following errors are raised:
Failed to resolve template field 'sql' jinja2.TemplateNotFound
Lifecycle notes
DuckDbHook runs the CLI with start_new_session=True, so a process-group kill operation can stop an ADO wrapper and its DuckDB child when a task is killed or times out.
If a worker is terminated with SIGKILL (for example, because of an OOM exception or a docker kill command), on_kill() is not called and an orphaned DuckDB process may keep the file lock until reaped.
Examples
The provider includes several example DAGs that demonstrate common DuckDB provider operations. To run the example DAGs, modify your Airflow connection to match values used in a DAG (Connection ID, Host)
Basic operations
The following DAG demonstrates basic DuckDB provider operations. It uses a preconfigured Airflow connection to access the database, creates a new table, inserts test data, and fetches test data.
from __future__ import annotations
from datetime import datetime, timedelta
from airflow.providers.arenadata.duckdb.operators.duckdb import DuckDbOperator
from airflow.providers.arenadata.duckdb.version_compat import DAG
CONN_ID = "duckdb_default" (1)
default_args = {
"owner": "airflow",
"retries": 1,
"retry_delay": timedelta(minutes=1),
}
with DAG(
dag_id="example_duckdb_basic",
start_date=datetime(2024, 1, 1),
default_args=default_args,
schedule=None,
catchup=False,
tags=["example", "duckdb"],
) as dag:
create_table = DuckDbOperator( (2)
task_id="create_table",
sql="CREATE OR REPLACE TABLE demo(id INT, name VARCHAR);",
duckdb_conn_id=CONN_ID,
)
insert_data = DuckDbOperator( (3)
task_id="insert_data",
sql="INSERT INTO demo VALUES (1, 'alpha'), (2, 'beta');",
duckdb_conn_id=CONN_ID,
)
select_count = DuckDbOperator( (4)
task_id="select_count",
sql="SELECT count(*) AS c FROM demo",
duckdb_conn_id=CONN_ID,
)
create_table >> insert_data >> select_count (5)
| 1 | Airflow connection ID used to access the DuckDB database. |
| 2 | Task to create a new table. |
| 3 | Task to write data to the table. |
| 4 | Task to retrieve the number of rows in the table. |
| 5 | Task dependency chain. |
Work with DuckDbSqlSensor
from __future__ import annotations
from datetime import datetime, timedelta
from airflow.providers.arenadata.duckdb.sensors.duckdb import DuckDbSqlSensor
from airflow.providers.arenadata.duckdb.version_compat import DAG, Param
CONN_ID = "duckdb_default" (1)
default_args = {
"owner": "airflow",
"retries": 1,
"retry_delay": timedelta(minutes=1),
}
with DAG(
dag_id="example_duckdb_sensors1",
start_date=datetime(2024, 1, 1),
default_args=default_args,
schedule=None,
catchup=False,
tags=["example", "duckdb", "sensor"],
params={
"table": Param("events", type="string"),
"min_id": Param(1, type="integer"),
},
) as dag:
wait_inline = DuckDbSqlSensor( (2)
task_id="wait_inline",
sql="SELECT count(*) AS ready FROM {{ params.table }}",
duckdb_conn_id=CONN_ID,
mode="reschedule",
poke_interval=30, (3)
timeout=300,
)
wait_from_sql_file = DuckDbSqlSensor(
task_id="wait_from_sql_file",
sql="queries/wait_until_ready.sql", (4)
duckdb_conn_id=CONN_ID,
mode="reschedule",
poke_interval=30,
timeout=300,
)
wait_inline >> wait_from_sql_file
| 1 | Airflow connection ID used to access the DuckDB database. |
| 2 | The task that waits until the SQL statement returns a truthy result (not False, 0, null, or empty string). |
| 3 | Time interval between the pokes. |
| 4 | The task that waits until the SQL in file returns a truthy result. |
Override database path
A path to the .duckdb database file is specified during Airflow connection creation. However, you can override the database path when instantiating an operator. For example:
select_count = DuckDbOperator(
task_id="select_count",
sql="SELECT count(*) AS c FROM demo",
database="/tmp/example_duckdb_sql_file.duckdb", (1)
duckdb_conn_id="duckdb_default",
output_format="json",
)
| 1 | Overrides the database path specified in Airflow connection. |