Open-source ETL/ELT you deploy on your own servers or cloud. Built on DuckDB: no-code/low-code visual pipelines or SQL, 385 components, dbt, CDC, data quality, reverse ETL, lineage, MCP for AI agents. No vendor cloud, no per-row billing.
1,287
stars
1,222
commits
Rust
primary language
Sep 9, 2026
updated
Duckle is an open-source ETL platform for teams who want their pipelines running on their own infrastructure. Author on a canvas, in Python or in SQL, then ship the same file to your own server or cloud account: duckle-runner serve runs it headless on a schedule, in Docker or on a box you own, with a web console, roles and an audit trail. Every pipeline is one file in git, so it outlives whoever wrote it. It compiles to SQL on DuckDB and uses every core you give the box, so a bigger instance is a faster pipeline: 96 million rows out of Postgres to Parquet in 39.9s. No vendor cloud. No per-row billing. No lock-in.
Duckle is an independent open-source project by SlothFlowLabs. It builds on the DuckDB engine but is not part of, affiliated with, or endorsed by DuckDB Labs or MotherDuck.
|
Get started |
Use the product |
Reference |
Resources |
An open-source ETL platform you run on your own infrastructure. Drag sources, transforms, validators and sinks onto a canvas, wire them together, and press Run. Duckle compiles the graph to SQL and executes it on a real columnar engine, with live previews, the generated SQL visible on every node, and no hidden state.
You build a pipeline on a laptop and deploy that same file to a server, where it runs on a schedule under a web console with roles, alerts and an audit log. Nothing is rewritten in between, and nothing is metered.
In short: a free, open-source, single-engine alternative to hosted, per-row-priced ETL platforms like Fivetran and Airbyte - one pipeline for ingest, transform, and load that runs anywhere, and can also run dbt on DuckDB inside the same tool.
Three things set it apart:
| Visual, never opaque | The canvas compiles to SQL you can read, and every node has a live preview tab. No black box. |
| An assistant with no API key | Runs in-process by default, or against your own OpenAI-compatible endpoint. Your prompts and your data stay inside your infrastructure either way. |
| Single-file binary, no bundled DB | 73 to 110 MB depending on platform (it embeds the headless runner + MCP server). DuckDB downloads on first launch with a guided step. AI engine is opt-in. |
| Native speed | Execution runs through DuckDB: vectorized, columnar, local. A clean-and-export job that crawls in a spreadsheet finishes in milliseconds. |
| Git-friendly by design | Pipelines, connections, contexts, and routines persist as plain files in a folder you pick. Diff them, branch them, review them. |
| 360+ components ready today | Files, databases, warehouses, lakehouses, object stores, SaaS APIs, NoSQL, streaming brokers, vector DBs, FTP, IMAP, SMTP. Each is covered by tests. |
| Honest about scope | Single-machine and embedded by design. Built to make local and small-team data work fast, not to replace a distributed warehouse. |
| 60 UI languages | Topbar, palette, chat assistant, properties panel, and common dialogs ship localized. English, Spanish, Chinese (Simplified + Traditional), Hindi, Arabic, Portuguese (Brazil), Bengali, Russian, Japanese, Punjabi, German, Korean, French, Vietnamese, Telugu, Marathi, Turkish, Tamil, Urdu, Persian, Polish, Italian, Ukrainian, Indonesian, Thai, Dutch, Hebrew, Swedish, Greek, Czech, Hungarian, Romanian, Filipino, Malay, Norwegian, Danish, Finnish, Catalan, Bulgarian, Slovak, Croatian, Serbian, Slovenian, Lithuanian, Latvian, Estonian, Khmer, Burmese, Sinhala, Nepali, Swahili, Afrikaans, Welsh, Irish, Icelandic, Albanian, Azerbaijani, Mongolian, Kazakh. RTL (Arabic, Hebrew, Persian, Urdu) supported. Switch languages from the topbar globe. |
| Open source | Dual-licensed MIT OR Apache-2.0. Yours to use, fork, and extend. |
Real pipelines, built and run in Duckle - not mockups.
A 5M-row pipeline: a CSV, a Parquet file, a DuckDB table, and a SQLite table enriched through one visual Map (3-way join), no SQL.
Left: the visual Map editor - main plus lookups, per-output expressions, an inline filter. Right: Parallelize fanning out aggregate, window, and top-N branches.
One run, many branches: 16 nodes finish in a few seconds. Concurrency auto-detects from CPU cores; branches write to Parquet, CSV, DuckDB, and SQLite at once.
Left: DuckLake CDC change-feed mirrored via upsert + delete propagation (100k rows). Right: watermark incremental load over 5M rows, advancing state only on a fully successful run.
samples/orders.csv, hit Autodetect schema. Drag a Filter, wire it up. Drag a Parquet sink with an output path. Press Run, watch the nodes light up.That's a real, native ETL pipeline built and run in under a minute. CSV is just the easiest first node; swap in Parquet, JSON, S3, Snowflake, MongoDB, or Stripe the same way.
Pick the binary for your OS from the latest release:
| OS | Asset | How to run |
|---|---|---|
| Windows | Duckle-windows-x64.exe | Double-click. Unsigned binary - Windows SmartScreen will warn the first time; click "More info" -> "Run anyway". |
| macOS (Apple Silicon) | Duckle-macos-arm64 | chmod +x Duckle-macos-arm64 && ./Duckle-macos-arm64. Right-click -> Open the first time to bypass Gatekeeper. |
| Linux (x86_64) | Duckle-linux-x64 | chmod +x Duckle-linux-x64 && ./Duckle-linux-x64. Requires WebKitGTK 4.1 (libwebkit2gtk-4.1-0 on Debian / Ubuntu). |
The single-file binary above is all you need for Build Pipeline too: the headless runner is embedded into the app at build time, and exporting a pipeline produces ONE self-contained executable (the engine, the DuckDB CLI, any needed extensions, and the resolved pipeline are all inside that one file). Copy that single file to your server and run or schedule it - no separate runner download required.
One command, nothing installed: it scaffolds sample data and a pipeline, compiles it to SQL, runs it on DuckDB, and shows you the rows.
uvx duckle quickstart
Paste this into Claude Code, Cursor, or Codex:
Run
uvx duckle quickstartto build my first pipeline and run it
Nothing to install first. The agent fetches Duckle and the DuckDB engine on demand, runs a real pipeline, and shows you the rows.
If you do not want the desktop studio, install just the headless runner. It is about 27 MB rather than 100 MB or more, has no GUI dependency, and is what a build step actually needs.
pip install duckle
That is the whole install. It brings the DuckDB CLI with it (via the duckdb-cli package published by the DuckDB Foundation), so there is nothing else to fetch and it works offline. Wheels ship for Linux, macOS and Windows on x86-64 and arm64.
It also gives you a Python API, where pipelines are built as code and executed by DuckDB rather than by Python:
import duckle
from duckle import col
(duckle.read_csv("orders.csv")
.where(col.amount >= 20)
.derive(total="round(amount * 1.2, 2)")
.write_parquet("out.parquet")
.run())
Python expressions compile to vectorized SQL at plan time, so no rows pass through the interpreter. See the PyPI page for the full API.
The same package provides the duckle command-line runner for CI, cron, and containers - it bundles the headless runner and the MCP server per platform:
pip install duckle # or run ad hoc, no install: uvx duckle --help
Pipelines execute as SQL on the DuckDB CLI, so the runner needs a duckdb on PATH or DUCKLE_DUCKDB_BIN set (pip install duckdb-cli is the quickest route). Validation does not:
duckle validate # compile-check every pipeline under ./pipelines
duckle validate --json # machine-readable, for a CI step
duckle --pipeline my.json # run one
validate opens no source and writes no sink, so it needs no engine, no credentials and no network. Exit codes are stable: 0 clean, 1 a real finding (a pipeline failed or did not compile), 2 the runner could not start (bad usage, unreadable file, missing engine).
The binary is 73 to 110 MB depending on platform (it embeds the headless runner and the bundled MCP server). On first launch you'll be guided through downloading two engines into your app-data directory:
| Engine | Size | Required? | What it powers |
|---|---|---|---|
| DuckDB CLI | ~30 MB + extensions | Yes - cannot run pipelines without it | Every source / transform / sink that runs as SQL |
| Duckie AI Assistant | ~1.1 GB (llama-server + Qwen 2.5 Coder 1.5B GGUF) | Optional | The chat sidebar that generates pipelines from natural language |
App-data location:
%APPDATA%\io.duckle.app\engines\~/Library/Application Support/io.duckle.app/engines/~/.config/io.duckle.app/engines/Delete the engines/ folder if you ever want to force a fresh install.
A worked example using the bundled samples/orders.csv data.
samples/orders.csvmain output port to the Filter's main input.status = 'paid' (you can write raw SQL or use the visual builder)pass (rows matching) and reject (rows that don't).pass port to the Parquet sink.paid_orders.parquet. Write mode: overwrite. Compression: zstd.You build a pipeline on your laptop. The server runs that same file. Nothing is rewritten, exported or converted in between.
flowchart LR
D["Duckle Desktop<br/>your machine"] -->|deploy, needs admin| W
B["Console in a browser<br/>your machine"] -->|turn it on, needs operator| W
W["Workspace on your server<br/>a new schedule lands OFF"] --> C["Scheduler<br/>every 15s, takes what is due"]
C --> R["It runs<br/>on that box, unattended"]
R --> O["Run history, logs, metrics,<br/>alerts, and an audit log"]
O -->|you watch it here| B
| How | What you get | |
|---|---|---|
| Server | duckle-runner serve --workspace /srv/pipelines | Headless web console, cron scheduler, roles, audit log, alerts |
| Docker | Dockerfile.web | The same console in a container, behind your own ingress |
| CI | duckle-runner --pipeline p.json | Any runner. Exit codes and NDJSON logs, nothing to install |
| Standalone | Build Pipeline | One self-contained executable. Drop it on a box, run it from cron or systemd |
| Desktop | The app | Author, debug and inspect. Optional, and never required to run anything |
Nothing here depends on a person's machine being switched on:
Working recipes for AWS (EC2, ECS, EKS), Azure (VM, Container Apps, AKS) and Google Cloud (Compute Engine, GKE), with manifests and the mistakes worth avoiding, are at duckle.org/deploy. Three things worth knowing before you start:
--token, set DUCKLE_CONSOLE_TOKEN, or create accounts with duckle-runner console add-user before exposing it. An empty value is refused rather than treated as absent, so an unresolved secret fails loudly instead of opening that window. Who can do what, and how one request is decided, is set out under Sign-in and roles.serve, not in the editor. Start the editor with schedules armed and it now says so rather than leaving you to wonder why nothing fired.GET /healthz needs no credential and answers ok, so a Kubernetes probe or a load balancer can check liveness without holding a token. Every other route is authenticated, so pointing a probe anywhere else reports the pod unhealthy forever.POST /api/deploy lands a pipeline on a server from wherever it was authored, with the schedule it should eventually run on:
curl -X POST https://duckle.internal/api/deploy \
-H "Authorization: Bearer $DUCKLE_TOKEN" \
-d '{"name":"orders-load",
"pipeline": '"$(cat orders-load.json)"',
"schedule":{"intervalMinutes":30}}'
Two things are deliberate. The schedule arrives disabled, so a cadence someone set while testing on a laptop cannot start firing the moment it reaches production; enabling it is a separate call. And deploying needs admin while enabling needs operator, because a deployed pipeline runs shell and SQL on that host: shipping the code and starting it are two acts, and the audit log records both with the name of whoever did them.
A person signs in and gets a session. A machine has no browser and nobody to rotate a password, so it gets a key of its own:
duckle-runner console key-add ci-deployer --role admin --expires-days 90
duckle-runner console key-list # role, state, and when each was last used
duckle-runner console key-revoke ci-deployer
A key carries its own role, so a deploy runner can be admin while a metrics scraper is viewer. It is printed once and stored only as a hash, so a lost key is replaced rather than recovered. key-list shows when each was last used, which is the question actually worth answering before revoking one, and revoking takes effect immediately on a console that is already running rather than at the next restart. Revoked keys are marked rather than deleted, so a key that turns up in an old log can still be named.
Accounts, sessions and keys live in .duckle/console.db. An existing console-users.json is carried into it on first start and renamed to .migrated, so an upgrade neither locks anyone out nor destroys the only copy of a credential store. For the roles, and a diagram of how one request is decided, see Sign-in and roles.
duckle-runner serve is an ordinary service. Run it on EC2, EKS, a VM or a container next to everything else you operate, and scale it the way you scale any service:
DUCKLE_THREADS when you would rather it did not take the whole machine.memoryLimitMb per stage, or a workspace default, and spill to disk past it.DUCKLE_MAX_CONCURRENT_RUNS raises how many run together; it ships at 1 so an unattended server stays predictable until you decide otherwise.duckle-runner work drains a queued batch from as many workers as you start, on as many hosts as you like, each claiming its items under a lock so nothing runs twice.Measured, rather than asserted:
The one thing Duckle does not do is split a single query across a cluster the way a distributed warehouse does. When you need that, push the work down into the system that has it and let Duckle orchestrate around it.
Want the studio to publish straight to a running server instead? That is the other route: connect a server once, then Deploy to a server from the editor. Step by step in docs/current/server-deployment.md.
Promoting from CI instead, or driving Duckle from Airflow, Dagster or Temporal? See docs/current/ci-and-orchestration.md, with copyable GitHub Actions and GitLab CI templates in docs/ci/.
Want to know exactly what crosses the wire, and where every credential is stored? docs/current/client-server-architecture.md is the diagrammed answer, sharp edges included.
The in-app scheduler runs only while Duckle is open. To run a pipeline on a server with no desktop app, Build Pipeline turns it into ONE self-contained executable - the equivalent of a standalone "Job".
Right-click a pipeline (in the project tree or on the canvas) and choose Build Pipeline. The output is a single file named after the pipeline (orders_etl.exe on Windows, orders_etl on macOS / Linux) that embeds everything it needs:
On first run it self-extracts to a temp cache and uses its own embedded DuckDB, so the server needs nothing installed - no Duckle, no DuckDB. There is no folder to copy, no run.sh, and no separate runner download. A CSV-to-CSV pipeline builds to about 28 MB; only the extensions a pipeline uses are bundled, so the file stays lean.
./orders_etl # or orders_etl.exe on Windows
The process exits 0 on success and non-zero on failure, and writes the same NDJSON run logs under logs/ (Splunk / Dynatrace friendly).
Build options
| Option | What it does |
|---|---|
| Target OS | Pick Windows, Linux, or macOS in the build dialog. The native OS always builds; a Linux server file can be cross-built from any host (the Linux engine is bundled for you), while a macOS file can only be produced on a Mac. Appending the payload makes the file unsigned, so do not codesign / Authenticode-sign it. |
| Context | Pick a context at build time; its non-secret variables are baked into the pipeline. |
| Secrets: Environment | Each secret becomes a ${ENV:KEY} placeholder, so nothing sensitive is written into the file. The runner resolves real environment variables first, then a secrets.env (KEY=VALUE lines) placed next to the file. |
| Secrets: Passphrase | Secrets are encrypted inside the file with AES-256-GCM, decrypted at run time from the DUCKLE_BUNDLE_PASSPHRASE environment variable. |
Schedule it with whatever the server already has - point the OS scheduler straight at the file:
# Linux cron - run every day at 02:00
0 2 * * * /opt/duckle/orders_etl >> /var/log/orders_etl.log 2>&1
On Windows use Task Scheduler; on macOS a launchd plist; on Linux a systemd timer. Full examples in docs/current/scheduler.md.
Run against an existing workspace - the same embedded headless runner can also execute a pipeline JSON directly, resolving context the way the app does:
duckle-runner --pipeline /path/to/pipeline.json [--workspace /path/to/workspace] [--duckdb /path/to/duckdb]
follow)A scheduled pipeline already consumes a stream without gaps: a source that
tracks its position (src.kafka with trackOffset, xf.incremental) resumes
where the last successful run stopped. What a schedule cannot give is
latency - the scheduler wakes every 15 seconds, and every run pays process
start, DuckDB resolution and document parsing again.
follow keeps the same execution model and removes that per-batch overhead.
The document is read and resolved once, the engine is built once, and the
pipeline then runs in a loop. Each pass is one micro-batch:
duckle-runner follow /path/to/pipeline.json --idle-ms 500
| Flag | Meaning |
|---|---|
--idle-ms N | wait N ms after a pass whose sinks wrote nothing (default 1000) |
--max-batches N | stop after N passes (default: until stopped) |
--on-error stop|continue | stop on a failed batch (default), or keep going |
A failed batch never advances the source position. The position is queued
during the run and written only when the run reaches ok, which is after every
sink has written - so a failure anywhere, transform, quality gate or sink,
leaves the position where it was and the next pass re-reads exactly the records
that did not land. Killing the process is safe for the same reason; Ctrl-C
finishes the batch in hand first, which only saves you a truncated output file.
That ordering is the difference between a correct micro-batch loop and a lossy one, so it is covered by a regression test that fails if the position ever advances past a batch that did not land.
backfill)Production deployments are headless, so replaying from an earlier point should not mean getting at the server's workspace through a GUI:
duckle-runner backfill list --pipeline ./pipelines/daily.json
duckle-runner backfill set --pipeline ./pipelines/daily.json --node inc --value 2026-01-01 --type TIMESTAMP
duckle-runner backfill clear --pipeline ./pipelines/daily.json --node inc
duckle-runner backfill list --pipeline ./pipelines/daily.json --json # for CI and agents
Five node kinds keep state in that folder, and only two resume from a value a
person can write down. xf.incremental (a watermark) and
src.ducklake.changes (a snapshot id) can be set; a src.kafka resume offset,
a src.spool byte position and an xf.tumble buffer pointer are listed and can
be cleared, but set on them is refused. Writing {value,type} over a
tumbling window's state would drop the pointer to the rows it is holding and
delete them on the next run, with nothing to report it.
Clearing is not always a full reload, and the tool says so: a Kafka node with
startFrom: latest skips whatever is already in the topic when it has no saved
offset, so clearing it moves PAST that backlog rather than replaying it.
The same three operations are on the console API and MCP, so a replay can be driven from CI or an agent as well as the CLI:
GET /api/watermarks?file=pipelines/daily.json viewer
POST /api/watermarks?file=pipelines/daily.json operator
DELETE /api/watermarks?file=pipelines/daily.json&node=ID operator
Reading needs a viewer; changing what the next run processes needs an operator. All four surfaces - desktop panel, CLI, API, MCP - call the same engine functions, so the kind guard cannot be bypassed by picking a different one.
listen + src.spool)src.webhook and src.websocket collect INSIDE a pipeline run: they bind or
connect, take N messages or time out, and stop. Right for a one-shot capture,
wrong for anything continuous - between runs the port is closed and arriving
requests are refused. Under follow that gap is every batch boundary.
listen is the other half. It keeps the listener up and appends what arrives
to an append-only NDJSON spool; a pipeline reads that spool with src.spool,
from wherever the last successful run stopped:
duckle-runner listen --port 9000 --spool ./spool/hooks.ndjson --path-filter /hooks
duckle-runner follow ./pipelines/hooks.json --idle-ms 500
Arrival is decoupled from processing, so a slow batch, a failed batch or a restart costs nothing that already arrived. Append-only plus a byte offset is the whole trick: the reader never deletes and the writer never rewrites, so there is no race between them.
A record is {received_at, method, path, headers, json|body} - a JSON body is
embedded under json so the pipeline can address its fields, and anything else
is kept verbatim under body rather than dropped for not parsing. The spool is
written and flushed BEFORE the 200 goes out, because a 200 tells the sender its
delivery is safe and webhook senders do not retry those.
--memory-limit, --threads, --max-temp-size)Duckle targets one machine and will use it. On a dedicated box that is what you want; on a shared server one unexpectedly large job should not be able to take everything else down with it.
duckle-runner --pipeline ./daily.json --memory-limit 24GB --threads 8 --temp-dir /data/duckle-tmp --max-temp-size 300GB
--max-temp-size is the one worth setting deliberately. DuckDB's own
default is 90% of available disk space, so without it a single large join or
sort can fill the volume the OS is on - which is an outage, not a slow
pipeline. --memory-limit is a spill threshold rather than a hard ceiling:
above it DuckDB writes to the temp directory and keeps going, so the limit
buys predictability, not failure.
Each run spills into its own subdirectory of --temp-dir. Pointing several
concurrent runs at one shared directory is what a person does to move spill
onto a bigger disk, and it used to make them unsafe: four concurrent spilling
queries sharing a directory lost 3 of 12 to segfaults and delete failures.
The flags set the same variables the engine reads (DUCKLE_MEMORY_LIMIT,
DUCKLE_THREADS, DUCKLE_TEMP_DIR, DUCKLE_MAX_TEMP_DIR_SIZE), so a flag, a
workspace-wide export and a per-stage setting all land in one place, with the
most specific winning.
src.changed)A pipeline that watches a bulk source should not pay for the object to find
out whether it was needed. src.changed compares what a HEAD or an SFTP stat
reports against the last fingerprint it successfully processed, and emits
a row only for what moved. https://, s3:// (including MinIO, Backblaze B2,
Cloudflare R2 and other S3-compatible stores, through a saved connection or
credentials on the node) and sftp://.
Two shapes, because they are the same question asked of a different number of objects:
sftp:// directory or an s3:// prefix of immutable
files. Lists it, compares each entry, and emits the new and changed ones as
ordinary rows for a ctl.foreach or an artifact copy downstream. S3 listings
follow continuation tokens, so a prefix larger than one page is enumerated
fully rather than silently truncated at the first thousand.Rows carry uri, name, size, modified_at, etag, fingerprint and
status (new / changed).
A quiet poll is not a plain success. When nothing changed the node reports
unchanged, so a working poll and a broken one are told apart - a healthy
source can be unchanged hundreds of times between updates, and that has to
stay countable.
Fingerprints are conservative on purpose. None of the signals are guarantees: an ETag can be absent, can weaken under compression, and on S3 is a digest-of-digests for a multipart upload rather than the object's hash; Last-Modified has one-second resolution; SFTP offers mtime and size. A missing or unreadable signal therefore counts as changed. Re-reading something unnecessarily costs compute; skipping something that did change loses data and reports nothing.
What was processed advances only when the whole run succeeds, and only for
rows that were actually emitted - so a failure downstream re-offers the same
files, and a run capped by maxEntries does not mark the remainder as done.
src.ducklake.maintain)A lakehouse that is written to continuously eventually needs maintaining as well as filling: frequent incremental writes leave many small files, snapshots accumulate, and files stay referenced longer than they need to be. Those operations used to live outside Duckle.
Each operation is one DuckLake function, and its options are that function's options - compact, rewrite files heavy with deletes, expire snapshots, clean up files an expired snapshot released, delete orphaned files, flush inlined data, or read per-table storage statistics. Nothing here invents storage semantics, so what it does follows the installed DuckLake rather than anything Duckle decided.
The result comes back as ordinary rows, which is what lets a quality check
or an alert read a compaction the way it reads anything else, and the node
reports what changed: ducklake compact: 1 row(s) - files 4 -> 1, 1.1 KB -> 513 B.
Three things about deleting, since that is where this gets dangerous:
Two maintenance runs against one catalog serialise on a lock rather than racing, so a weekly compaction overlapping a monthly cleanup waits instead of failing a two-hour job at its commit.
xf.artifact.copy)An artifact is a reference - a uri, a media type, a size, a hash - so a pipeline can carry one around for nothing. At some point the actual bytes have to move, and that is this step: between "the feed says there is a new 4GB bundle" and "it is in our raw zone, hashed, and we can prove which bytes we parsed".
It reads a uri column - whatever src.changed, src.artifact or a query
produced - and copies from https://, s3://, sftp:// or a local path to
an s3:// prefix or a local directory.
Streamed and hashed in one pass. Memory is bounded by the part size rather
than by the object, so a 40GB model file does not become 40GB of RSS, and the
sha256 recorded is of the bytes that actually transferred. Reading twice -
once to hash, once to upload - would double the transfer off a remote source;
hashing first would mean holding the whole thing.
Naming is keep (the source's file name), path (its layout preserved under
the prefix) or hash (content-addressed, which makes the store immutable and
de-duplicating at the cost of reading each source twice, because the key is
the hash). A source-derived name can never climb out of the destination
prefix.
ifExists: skip is the default and is what a raw zone wants: re-running a
feed does not re-upload what already landed. The row still comes out, with
copied = false, because downstream still needs to know the artifact exists.
Emits uri, source_uri, name, media_type, size_bytes, sha256 and
copied.
Remote artifacts reach the signed run manifest. .ducklock pinned local
file inputs by path, and a remote object has no path - so the boundary that
matters most in a raw-zone pipeline, where the bytes came from, was the one
thing the manifest did not record. Every object a run reads or writes now
appears in it with its uri, size, media type, and either a sha256 when the
bytes actually passed through the run or an ETag and mtime when they did not.
An object that was merely observed carries no hash, because claiming one would
be a lie. The manifest also records the resource limits the run was given, so
two runs that spilled differently can be told apart from two runs handed
different budgets.
xf.tumble)Aggregating a stream by time needs a window to stay open across batches, and
needs to know when it can be closed. xf.tumble assigns each row to a
fixed-size bucket by its EVENT time, holds it until the bucket closes, then
emits it with window_start / window_end for an ordinary GROUP BY
downstream.
Closing is decided by a watermark - the greatest event time seen so far, across runs - not by the wall clock. Replaying last year's data therefore produces last year's windows, instead of finding every one of them older than "now" and closing the lot at once.
allowedLateness holds a window open past its end for out-of-order arrivals.
Anything that arrives after its window was already delivered is dropped and
counted, not emitted: sending it would hand a downstream consumer a second,
partial copy of a window it already has, with different numbers in it.
The rows in still-open windows and the watermark ride the same deferred flush as every source position, so a batch that fails downstream leaves them intact and re-processes rather than losing what it was holding.
To run and monitor pipelines on a server with a browser instead of the desktop app, start the built-in web panel - it is part of the same duckle-runner binary, so there is nothing extra to install:
duckle-runner serve --port 8080 --workspace /path/to/workspace
Open http://localhost:8080. The panel has eight views:
Runs execute in-process through the same engine, are written to the same run history (<workspace>/runs/) and logs (<workspace>/logs/), and a built-in scheduler triggers any pipeline whose schedule has elapsed - so the server itself runs your schedules, no OS cron needed.
A schedule runs one pipeline. A plan runs several, in steps: everything inside a step goes at once, and the next step waits for it. A step that fails stops the ones after it, so nothing runs against data that was never produced.
That is the shape most nightly loads already have. Without it they get written as three schedules set a few minutes apart and hoped over, which works until the extract takes four minutes instead of two.
Build one wherever you are: the Plans tab in the web console, or the Plans tile under Operate in the desktop app. Add a step, put pipelines in it, and the card draws the chain it will run.
EXTRACT PUBLISH
orders.json --> export.json
customers.json
Two things worth knowing:
serve on your server or by the desktop app on a shared workspace - both read plans.json and schedules.json, and both decide it the same way.Plans live in <workspace>/plans.json, so they are a file in git alongside the pipelines they order.
Start from where you actually are.
Running it on your own machine? Nothing to do. On 127.0.0.1 with no accounts the console is open, because anyone who can reach it is already sitting at the machine, and asking them for a password would protect against an attacker who has already won.
Put it on a server and it refused to start? That is the feature. The console can run any pipeline in the workspace, and a pipeline can run shell and SQL, so reaching it is the same as running code on that host. A bind it cannot authenticate fails rather than serving anyone and printing a warning nobody reads. The shortest way past it:
DUCKLE_CONSOLE_TOKEN=<secret> duckle-runner serve --host 0.0.0.0 --port 8080
More than one person? Give each of them their own, so the audit log can name them. The token is printed once and kept only as an Argon2id hash:
duckle-runner console add-user reporting --role viewer
duckle-runner console add-user ops --role operator
duckle-runner console list
A machine needs in? CI, a scraper, or your own laptop deploying: those have no browser and nobody to rotate a password, so they get a key instead of an account. See API keys.
| Role | Can |
|---|---|
viewer | Read the dashboard, run history, logs, schedules and catalog. |
operator | Everything a viewer can, plus run pipelines and change schedules. |
admin | Everything an operator can, plus deploy pipelines, connections, credentials, the audit log and the workspace itself. |
The split follows what an action can destroy, not which screen it lives on. It is why deploying a pipeline needs admin while turning its schedule on needs operator: shipping code to a host and deciding when trusted code runs are different sizes of decision.
Three ways to prove who you are, one identity, one check, and every outcome recorded:
flowchart LR
R([Request]) --> C{"Session cookie?"}
C -->|within 12h| ID["Identity<br/>name + role"]
C -->|no| B{"Bearer token?"}
B -->|API key| ID
B -->|account token| ID
B -->|nothing| U["401<br/>sign in"]
ID --> P{"Role enough<br/>for this route?"}
P -->|yes| OK["It happens"]
P -->|no| F["403<br/>refused"]
OK --> A[("audit log<br/>who, what, when")]
F --> A
U --> A
Two things follow from that shape. A refusal is recorded as carefully as a success, so audit --outcome denied answers "who is reaching for what they do not have". And a route with no entry in the permission table needs admin, so a route added later is locked down rather than accidentally left open.
A browser trades its credential for a session cookie, so it never stores the credential itself: the cookie carries a random session id, is HttpOnly and SameSite=Strict, is marked Secure when a proxy tells Duckle the browser is on https, and lasts 12 hours. Sessions survive a restart, so a rolling deploy does not sign your team out.
Accounts, sessions and keys live in <workspace>/.duckle/console.db. Nothing in it can be replayed: an account token is an Argon2id hash, and a session id and an API key are both generated with 256 bits of entropy and stored as SHA-256, so a copy of the file or a backup of the workspace admits nobody. An older console-users.json is carried in on first start and renamed .migrated, so upgrading neither locks anyone out nor destroys the only copy of a credential store. The same accounts, roles and keys cover duckle-runner web.
Read it back from the Audit view, or from a terminal with no server running:
duckle-runner audit # newest first, 50 by default
duckle-runner audit --outcome denied # who reached for what they do not have
duckle-runner audit --actor ops --action schedule # one person, one family of actions
duckle-runner audit --limit 500 --json # for a collector
allowed means the caller was permitted to proceed, not that the work then succeeded - run history answers that. Reads are not recorded, so a dashboard polling every few seconds does not bury the entries worth seeing. A page says when older entries exist beyond it, and a line that will not parse is counted rather than silently skipped.
Still put it behind a reverse proxy if you need TLS.
The editor imports one job at a time, which is how you try Duckle. This is how you leave another tool: point it at a checkout and convert everything.
duckle-runner import ./legacy-jobs # convert the tree into ./imported
duckle-runner import ./legacy-jobs --out ./pipelines # somewhere else
duckle-runner import ./legacy-jobs --json # for a migration script
duckle-runner import ./legacy-jobs --strict # CI gate, exits 1 if anything needs a person
The folder layout is mirrored rather than flattened, because two jobs in different folders routinely share a name and flattening would silently drop one. Files that are not jobs - routines, contexts, SQL templates - are reported separately rather than counted as conversions, and no empty pipeline is written for them.
Reusable job bodies convert alongside jobs, and a job's children resolve to the files they became, so the master/child/joblet graph survives the move rather than arriving as a set of disconnected pipelines. A loop or iterate body is lifted into its own pipeline that the parent calls, which is why the file count comes out higher than the job count.
The closing tally is the number to decide on. It says how many jobs came across clean, how many need a person, and which components have no equivalent yet, sorted by how often they appear. On a real 125-file corpus that list had a single entry, a site-specific custom component: coverage is the head of the distribution, so a corpus usually converts far better than a raw component count suggests.
What remains is credentials and Java. Credentials were never in the job files: encrypted
passwords become ${ENV:...} placeholders and connections defined outside the job are
named so you can point them at a saved connection. Java is the part that needs a person,
and the report separates it so you can see how much there is:
A component with no equivalent is imported as a named placeholder and reported. That includes a job body's input and output ports: a child pipeline runs for its side effects, so it does not yet take rows from its caller or hand them back.
A SQL step that changes the database is reported, not converted. A SQL step returns
rows and compiles into a view, so a step carrying an UPDATE, MERGE or CREATE cannot
become one: it would reach the database wrapped in CREATE VIEW and fail there. That is
knowable at import, so it is said at import. On a 125-file corpus, 16 steps.
How a write writes is carried across. A warehouse sink records whether it appends rows or amends the ones already there, and importing that as the default write mode turned an append into a full-table replace - so on a table several nodes write to, each one erased the one before it. The write action now comes across, with the key it matches on taken from the columns the schema marks as keys. An action with no exact equivalent here is reported rather than widened in silence.
The order subjobs run in is kept. Most subjobs are not linked to each other at all; they run one after another in the order the file lists them at the end, and that order was being dropped. A job that wrote a table in one subjob and read it in the next then arrived as two things that could happen in either order. Branches of a parallel fork are the one part that genuinely does not run in declared order, so they are left out of the chain.
Intermediate work moves to DuckDB. A job written against a warehouse uses it as
working storage as well as a destination: it writes a staging table, reads it back, joins
it, writes it again, and every one of those hops is billed for rows that were produced on
this machine in the first place. So a table the imported project both writes and reads is
mirrored into <workspace>/.duckle/staging.duckdb as it is written, and the reads are
pointed at the mirror. The warehouse write is left exactly as it was, which is what makes
this safe to do unasked: every table still lands where it landed before, so nothing
downstream of the project can tell the difference. Only the reads move.
A read moves only when the whole of it can. A query that also names a table the project does not write still needs the warehouse to resolve it, so that read stays - and so does the staging table it reads, since a mirror would then be serving only half of what the project asks for. Neither does a read the job could run before its own write: within one pipeline the write has to lead to the read, by rows or by an ordering link, because a warehouse table nothing wrote yet holds stale rows while a local one is simply not there. A mapper's second input is judged by where its mapper sits, since that is when a lookup is loaded and nothing feeds the lookup itself; where such a read does move it is held until the mirror has been filled. A mapper that reads a table and produces the write back to it keeps reading the warehouse, because what the lookup feeds is what changes the table. Anything else, including a query assembled at run time, is mapped as it was.
Every other lineage view in Duckle answers about one pipeline. The catalog answers about the whole workspace, by joining pipelines through the assets they name: two pipelines that read and write the same table are connected whether or not anyone drew a line between them.
duckle-runner catalog lint # CI gate, exits 1 on findings
duckle-runner catalog diff main # what this branch does to the graph
duckle-runner catalog build # scan every pipeline
duckle-runner catalog assets # every table, file, topic and endpoint
duckle-runner catalog impact postgres://db:5432/sales.public.orders
duckle-runner catalog orphans # written here, read by nobody
duckle-runner catalog owners # what nobody has claimed
impact is the blast radius: the pipelines that read an asset, the assets they write, everything downstream of those, and how many hops away each is. Assets that could not be named are counted on every answer rather than dropped, so a partial graph never looks complete. An asset name nothing in the workspace uses exits non-zero, including under --json, so a mistyped name in a CI gate fails instead of reporting an empty blast radius.
build walks the whole workspace, skipping Duckle's own folders (runs, logs, connections, .duckle), so pipelines kept in subfolders are included. The saved graph records what it was built from, so it knows when the pipelines have moved on: the CLI rebuilds on read rather than answering from a stale graph, and the console says "pipelines have changed since this was built" instead of quietly presenting an old blast radius as current. The console reports it rather than rebuilding, because reading the catalog is a viewer action and rebuilding writes a file - Rescan is the operator's button. The check is stat only, so it costs nothing to make on every read; a same-length edit inside the same millisecond would slip through, which is the price of not hashing every pipeline on every read. Asset names never carry a credential: a mongodb://user:pass@host uri or an ODBC connection string is reduced to the address before it becomes a name, which also keeps the name stable when the password is rotated.
Add <workspace>/owners.json and it also tells you who to notify. Rules are globs and the first match wins, so a narrow rule above a broad one carves out an exception:
{
"assets": [
{ "match": "/lake/raw/pii_*", "owner": "Privacy Office", "contact": "privacy@example.com",
"description": "Landing zone for regulated source tables.", "tags": ["raw", "pii"] },
{ "match": "/lake/raw/*", "owner": "Data Platform", "contact": "data@example.com" }
],
"pipelines": [{ "match": "*-ingest-*", "owner": "Ingest Squad" }],
"terms": { "active customer": "Ordered in the last 90 days." }
}
The same file carries the human half of the catalog: an optional description
and tags per rule, and a workspace terms glossary for the words three teams
would otherwise each define differently. Every one of those is optional, so an
owners.json written before they existed still loads unchanged. They live here
rather than in a file of their own because they are authored, reviewed and
committed alongside ownership, and a second file would drift from this one.
Every run records which assets it read and wrote, with row counts, under the same names the graph uses - so the catalog can answer the first question anyone actually asks of an entry: is this current? A table nobody has written for three weeks is the interesting one, and no amount of structure reveals that. Only successful runs count towards freshness: a failed run may have written nothing, or half of something, and showing either as the last write would make a broken load look like a fresh table.
Assets also carry the columns the pipelines declare, unioned across every node that touches them - a pipeline reading three columns of a table another writes twenty to does not make the table three columns wide. They come from the schema a node already carries, so building the graph still opens no source and needs no credentials. No declared columns means none are known, which the catalog does not confuse with the asset having none.
In the desktop app this is the Data Catalog screen (Home -> Govern): search every asset by name, owner, tag or column; see who writes it, who reads it, what columns are declared and when it was last written; and set the owner, description and tags without leaving the app. Saving writes a rule for that exact name above any wildcard covering it, so describing one file never re-describes its neighbours. Read live schema opens the source on demand through a node that already reads it, so it authenticates the way the pipeline does - it is never done just because a screen was opened.
catalog lint is the gate for a CI job: it exits 1 when it finds something.
It reports ownership rules that match nothing - almost always a typo or a
renamed asset, and a failure that is otherwise silent, because the team the
rule names simply never gets told about anything - patterns that will not
compile (they own nothing, safely and invisibly), and nodes the graph could not
name. Unowned assets are reported but only fail under --strict: most
workspaces have a long tail nobody will ever claim, and failing CI over it on
day one is how a useful check gets deleted from the pipeline instead of acted
on.
catalog diff <rev> answers what a change does to the graph, which is the
question a review of a data platform actually asks: which assets appear, which
disappear, and - the one that matters most - which are still there but have
lost every pipeline that wrote them. A deleted asset is loud, because
something errors. An asset nothing writes any more is silent: no error, no
missing file, the table simply stops moving and whoever reads it finds out
weeks later. The revision is read straight from git's object store, so nothing
is checked out and it is safe to run on a dirty worktree.
The console's Catalog view now shows the same facts as the desktop screen -
description, tags, columns and freshness - because both are assembled by one
function in the engine rather than two that would drift. The same answers are
available over MCP as workspace_impact.
snk.email and snk.rest are pipeline nodes - they need wiring into every pipeline and cannot fire when a pipeline dies before reaching them. <workspace>/alerts.json watches the runs themselves, for both the desktop scheduler and the server:
{
"rules": [
{ "match": "nightly-*", "channel": "webhook", "url": "${ENV:SLACK_WEBHOOK}", "cooldownMinutes": 15 },
{ "match": "*", "channel": "email", "smtpHost": "smtp.example.com",
"from": "duckle@example.com", "to": ["oncall@example.com"] }
]
}
The webhook payload carries a text field as well as structured fields, so Slack, Teams and Discord render it directly. Three behaviours are deliberate:
cooldownMinutes (default 15) bounds it per pipeline and event.recovery event and ignores the cooldown, so nobody is left thinking an outage is still running. Ordinary successes are silent unless you add "on": ["success"].A schedule whose pipeline file has been renamed or deleted also raises an alert, instead of silently doing nothing.
Pipelines can run on cron, fixed interval, or file-watch triggers. Configure these in the Schedule panel (toolbar -> Schedule icon), not as graph nodes.
| Trigger type | Config | Example |
|---|---|---|
| Cron | Standard 5-field cron expression with optional timezone | 0 2 * * * (every day at 2 AM) |
| Interval | every N {seconds, minutes, hours, days} | every 15 minutes |
| File watch | Watch a directory for new/changed files matching a glob | /inbox/*.csv |
| Manual | Run-on-demand only (the default) | - |
Schedules persist to workspace/schedules.json and execute via the in-process scheduler crate. They survive app restarts but require Duckle to be running.
For headless / always-on schedules that run when Duckle is closed, build the pipeline into a standalone file and let the operating system's own scheduler run it - see Server deployment below.
Describe what you need. Duckie writes the pipeline.
The sidebar on the right is Duckie AI Assistant - powered by Qwen 2.5 Coder 1.5B running through llama.cpp, downloaded once (~1.1 GB) and then run entirely on your CPU. Ask in plain English; Duckie streams back a valid Duckle pipeline definition. One click drops it onto the canvas, ready to inspect, tweak, and run.
| Truly local | The Qwen model runs as a llama-server subprocess on 127.0.0.1. No API keys. No network calls. Disconnect your wifi and it keeps working. |
| Streamed responses | Tokens arrive as they're generated, with a blinking caret in the bubble. No "wait 20 seconds for the spinner to vanish" UX. |
| One-click insert | When Duckie produces a JSON pipeline, an Insert into canvas button appears. The graph populates with positioned nodes, wired edges, and the props the model chose. |
| Bring-your-own-model option | The chat plumbing is the same OpenAI-compatible HTTP interface used by xf.ai.llm / xf.ai.embed connectors. Point baseUrl at Ollama, llama.cpp, Cohere, OpenAI, Voyage - anything that speaks the OpenAI shape. |
| Sandboxed | The model has no fs / net / tool access. It can only emit text - your pipeline JSON. |
The most common job in data engineering: load a 20M-row CSV into DuckDB. One identical 2.49 GB file (20M rows of TPC-H lineitem, 16 typed columns), every tool measured at its best configuration, wall-clock time to land the data as a table.
How it was measured
Why Duckle is this fast: its 15.69s sits right on top of raw DuckDB's own load floor (~16s to fully parse and write all 20M typed rows into an on-disk table). Duckle wraps the engine with pipelines, connectors, and a UI, then gets out of its way. That is the entire design goal. A read-only scan or aggregate over the same CSV is far faster still; this benchmark measures the heavier "materialize it as a table" job that every ETL tool here performs.
A second, harder job against a live database: full-refresh extract of 95,988,640 rows of TPC-H lineitem (14 GB in Postgres 16) out to Parquet.
Run it yourself. The harness is in this repo at benchmarks/pg-to-parquet. ./bench.sh all brings up Postgres, generates the data at any scale factor, and times every tool you have installed. No timing is recorded until the output has been reopened and checked for the right row count and the right sum(l_orderkey), so a tool that writes a fast but wrong file gets a failure rather than a number.
Read it with these caveats
postgres_scanner plus COPY TO: no scheduling, no typing, no incremental state, no UI. It is there to show how much of the clock is the machine reading Postgres. Duckle landing 11% under it is the honest framing, not "Duckle beats DuckDB".Hardware, per-run numbers and the two measurement traps that produced wrong figures on the first attempt are written up in RESULTS.md.
Duckle is in public beta. The visual designer, the DuckDB execution engine, the scheduler, the cloud connectors, and the Duckie AI assistant all work today and are covered by 170+ integration tests across Linux, macOS, and Windows. The catalog is still growing and APIs may evolve before 1.0, but the day-to-day surface is stable enough for real work.
Scope, stated plainly: Duckle runs as a service on hardware you provision, and uses all of it. What it does not do is split one query across a cluster, so when a job outgrows the largest instance you want to pay for, push the work down into the source system or point the output at a warehouse, object store or lakehouse. It will not pretend to be a cluster.
The component palette ships 384 nodes so the roadmap is visible in the product itself:
docs/roadmap.mdDuckle is not a CSV tool with extras. It reads a broad set of formats and sources, ships a deep transform library, and writes to files, databases, object storage, vector DBs, message buses, and email.
113 sources available today.
| Group | Connectors | Status |
|---|---|---|
| Files | CSV, TSV, Parquet, JSON, JSONL / NDJSON, Excel (.xlsx), YAML, TOML, Fixed-width (mainframe / banking positional dumps), XML (slash-separated rowPath), Apache Avro (.avro / .ocf, pure-Rust) | Available |
| Geospatial files | GeoJSON, Shapefile, GeoPackage, KML, GPX, GML via the spatial extension | Available (lazy-loaded) |
| File Geodatabase | Esri File Geodatabase (.gdb) feature classes via ST_Read with a per-layer selector | Available (lazy-loaded) |
| Hugging Face | Hugging Face Hub datasets over hf:// (Parquet / CSV / JSON, globs, revisions); token for private or gated datasets | Available |
| Geospatial | Read GeoJSON / Shapefile / GeoPackage / KML / GPX / Esri File Geodatabase; write those plus GeoParquet; CRS-aware measurement, reprojection, spatial joins and predicates | Available |
| Lakehouse table formats | Apache Iceberg, Delta Lake, DuckLake (catalog in a local file or a postgres: / mysql: / sqlite: DSN, with the catalog schema and META_* parameters - including META_SECRET - settable on the node) | Available |
| Embedded databases | SQLite (read tables), DuckDB (read tables or run a query) | Available |
| Network relational DBs | PostgreSQL, MySQL, MariaDB, CockroachDB | Available (live CI for PG + MySQL) |
| Network relational DBs | SQL Server (TDS), Oracle (Instant Client at runtime), ClickHouse (HTTP API), IBM DB2 (IBM Data Server ODBC driver), Turso / libSQL (HTTP pipeline API - no driver install; libsql:// URLs accepted) | Available |
| Network relational DBs | generic JDBC | Planned |
| Object storage | Amazon S3, Google Cloud Storage, Azure Blob, HTTP(S), MinIO, Cloudflare R2, Backblaze B2 | Available (live CI for MinIO) |
| Cloud warehouses | MotherDuck, Snowflake (SQL API + PAT/JWT), BigQuery, Redshift (postgres ATTACH), Databricks SQL (Statement Execution + chunk follow), Azure Synapse (TDS), Teradata (ODBC, Windows / Linux), DuckDB Quack (May 2026 remote protocol - HTTP on :9494, SECRET-based token auth) | Available |
| Streaming | Apache Kafka / Redpanda (pure-Rust rskafka), NATS JetStream, GCP Pub/Sub (REST + auto-ack), RabbitMQ (lapin AMQP), AWS Kinesis (HTTP + SigV4 - no AWS SDK), WebSocket (ws:// / wss://, optional subscribe frame) | Available |
| Streaming | Pulsar, Event Hubs, multi-shard Kinesis | Planned |
| APIs and SaaS (REST) | Salesforce, HubSpot, Pipedrive, Zendesk, Intercom, Stripe, QuickBooks, Xero, Shopify, Notion, Airtable, Asana, Trello, ClickUp, Monday.com, GitHub, GitLab, Linear, Jira, Slack, Discord, Telegram, Twilio, Mailchimp, SendGrid, Segment - thin pre-configured wrappers over src.rest / src.graphql. src.rest takes a configurable API-key auth header name and offset pagination that stops on a body total_count. Salesforce Bulk (src.salesforce.bulk) - Bulk API 2.0 query source for migration-scale reads: SOQL as an async query job (query / queryAll), paged CSV result sets streamed to disk via Sforce-Locator, typed empty relations on 0 records | Available |
| APIs (protocols) | OData v4 (follows @odata.nextLink), SOAP / generic XML APIs (XML response parsing with namespace local-name match) | Available |
| Health data (DHIS2) | src.dhis2 reads the DHIS2 Web API: aggregate dataValueSets, paged metadata lists, tracker exports, and analytics/dataValueSet.json. snk.dhis2 imports back: chunked requests, importStrategy (CREATE_AND_UPDATE is DHIS2's upsert), dryRun, and real import-summary parsing, so conflicts and a non-zero ignored count fail the run instead of passing as a green HTTP 200. Auth via personal access token or HTTP Basic. Raw /api/analytics (columnar headers[] + rows[][]) is not supported | Available |
| NoSQL and search | Neo4j (Cypher over the HTTP Query API - self-hosted or Aura, no Bolt driver; optional $parameters), MongoDB (official driver), Cassandra / ScyllaDB (CQL), Elasticsearch / OpenSearch (from+size + search_after), Redis (SCAN + GET), CouchDB (_all_docs), DynamoDB (HTTP + SigV4 - no AWS SDK; auto-unwraps typed attributes) | Available |
| Vector / AI databases | pgvector (postgres ATTACH), Qdrant (/points/scroll), Weaviate (/v1/objects), Milvus (/v1/vector/query) | Available |
| Vector / AI databases | Pinecone (no list-all-vectors API), Chroma, LanceDB | Preview |
| File transfer | FTP / FTPS (pure-Rust suppaftp) and SFTP (SSH, pure-Rust russh + russh-sftp on the ring backend; password or private-key auth) - one File Transfer component, pick the protocol. Glob filter, base64 content per file. Host keys are verified: pin a SHA256 fingerprint to accept only that key, or leave it empty and the first key seen for a host is recorded in <workspace>/.duckle/known_hosts, after which a different key is refused. A host that presents an OpenSSH certificate is accepted only when it certifies the key you pinned. DUCKLE_SFTP_HOST_KEY_POLICY=accept-any opts out for a host whose key changes per connection | Available |
| Mailbox | IMAP (rustls TLS, mail-parser) - basic auth today, OAuth (gmail / o365) on the roadmap | Available |
| Webhook listener | Binds 127.0.0.1:port, collects N inbound HTTP requests with a timeout, parses JSON-object / JSON-array bodies into rows | Available |
| Desktop | System clipboard (pure-Rust arboard, auto-detects JSON-array shape) | Available |
| Repos | Git (commit log or file tree from a local working copy; shells out to system git CLI) | Available |
For CSV / TSV sources, the Schema panel accepts an optional per-column Format (a strptime token string such as %d/%m/%Y) on Date and Timestamp columns. Several date columns can each parse a different layout in one read - the column is read as text and re-parsed with its own format, working around DuckDB's single global date format. A value that does not match its format becomes null rather than failing the run. Set a Date or Timestamp column's Format to excel to convert Excel day-serials correctly. CSV sources also surface ignoreErrors (skip unparseable rows) and nullPadding (pad short rows with nulls) toggles in the GUI.
For JSON sources, a Format selector picks how the file is read (auto / array / JSON Lines / object), and a skip malformed records toggle drops records that fail to parse instead of failing the run.
130 transforms available today.
| Group | Operations |
|---|---|
| Fields | Map (visual mapper: joins a main input to up to 3 lookup inputs with inner / left joins and per-output expressions + filter), Project / Select, Cast, Rename, Add / Drop / Reorder Column, Coalesce, UUID v4 |
| Rows | Filter (visual or raw SQL, with reject port), Distinct, Sample, Top N / Limit, Sort, Skip, Top N per Group, Forward Fill, Backward Fill, Constant Fill |
| Aggregate | Group By, Rollup, Cube, Count, Window Aggregate, Cumulative, Approx Quantile (t-digest), Approx Count Distinct (HyperLogLog) |
| Join | Inner, Left, Right, Full Outer, Cross, Lookup, Semi, Anti, Spatial Join (Intersects, Contains, Within, Touches, Crosses, Overlaps, Equals, Covers, Covered by; fails naming both systems when the two geometry columns use different CRS, rather than returning zero rows) |
| Set operations | Union, Union All, Intersect, Except / Minus |
| Window | Row Number, Rank, Dense Rank, Lead, Lag, First Value, Last Value, NTile |
| Strings | Regex Replace, Regex Extract, Regex Match, Split, Concat, Trim, Case Change, Length, Substring, Format, Hash (md5 / sha1 / sha256), IP Parse, URL Parse, Text Similarity (Levenshtein / Jaro-Winkler / Jaccard), Base64, Pad, Text Match |
| Date / Time | Parse, Format, Extract Part, Date Diff / Add, Truncate, Timezone Convert, Time Bin, Current Timestamp, Epoch Convert |
| Numeric | Round, Modulo, Absolute, Logarithm, Power, Square Root, Bucketize, Z-Score, Clamp, Sign |
| JSON / nested | Parse, Stringify, Flatten, JSONPath Extract, Merge Objects, Array Aggregate, jq Filter (a jq program per row over a JSON column, run in-process by the pure-Rust jaq engine - no external jq, no subprocess) |
| Array | Explode / Unnest, Collect List, Element At, Contains, Distinct, Length, Zip Arrays to Table (headings + row-arrays -> one column per heading) |
| Pivot / shape | Pivot, Unpivot, Denormalize, Normalize, Transpose |
| Quality gate | Every check offers On failure: reject (route the bad rows to the reject port, the default), warn, or fail (stop the run). fail raises where the rows are counted, so a gate asked to stop a load stops it |
| CDC / SCD | Incremental Load (watermark column; saves the high-water mark to workspace state and advances only on a fully successful run), Diff Detect, SCD Type 1, SCD Type 2 (valid_from / valid_to / is_current), Merge / Upsert (universal across embedded, network, warehouse and Mongo sinks, with optional delete propagation driven by a CDC change-type column), DuckLake CDC change-feed reader, Row Hash (md5 / sha1 / sha256 fingerprint), Audit Stamp (_loaded_at / _loaded_date / _source / _batch_id) |
| AI / Search | Vector Similarity Search (cosine / L2 / inner product over FLOAT[N] via vss), Full-Text Search (BM25 via fts), Embeddings (OpenAI-compatible /v1/embeddings), LLM Transform (per-row chat completion with {column} templates), Classify (LLM-backed, normalizes to UNKNOWN), Text Chunker (RAG-ready, pure local), PII Redact (regex - emails / phones / SSNs / cards), Semantic Dedupe (cosine over precomputed embeddings) |
| Geospatial | Spatial Distance, Length, Perimeter, Area (each auto-picks the planar or spheroidal function from the geometry CRS, and rejects geometry with no CRS), Spatial Buffer (ST_Buffer), Spatial Intersects (ST_Intersects), Flip Coordinates (ST_FlipCoordinates - fix lat,lon vs lon,lat order), Define Projection (ST_SetCRS - stamp a CRS without moving coordinates), Reproject Geometry (ST_Transform between CRS, target CRS preserved on the output), Create Geometry (from X/Y, WKT, or WKB), Clip Geometry and Erase Geometry (two-layer overlays; the second layer is dissolved with ST_Union_Agg so a feature spanning several polygons is not duplicated, and both refuse to run when the two layers carry different CRS) |
| Debug | Log Rows, Assert (hard-fail on SQL predicate violation) |
All 6 AI transforms ship today. Three need a model API (LLM, Classify, Embeddings) and ride the apiKey-in-props pattern; three are pure-local (Chunk, PII Redact, Dedupe).
27 validators available today.
Validators split their input: passing rows continue on the main port, failures route to a reject port you can sink, count, or inspect.
| Component | Behavior |
|---|---|
| Not-Null Check | Pass rows with no nulls in the chosen columns |
| Range Check | Pass rows inside a numeric range (inclusive or exclusive) |
| Regex Match | Pass rows whose column fully matches a pattern |
| Uniqueness Check | Pass the first row per key; route duplicates to reject |
| Schema Validate | Reject rows where any expected column is null |
| Column Profile | Per-column stats (count, null %, distinct, min / max, quartiles) via SUMMARIZE |
| Describe | Column names + types of the input |
| Histogram | Value frequencies for one column, most-frequent first |
| Standardize | Trim + case-normalize + collapse inner whitespace, in place |
| Fuzzy Deduplicate | Keep the first row per near-duplicate cluster |
| Record Match | Self-join: emit pairs of rows above a similarity threshold |
| Expectation Suite | A reusable suite of rules plus a data-quality scorecard, so one node carries the whole expectation set |
| Data Contract | Enforcement gate holding the same rule suite, failing the run when the contract is broken |
| Freshness / SLA | Age is now minus the newest value in a column, checked against a maximum you set |
| Outlier Detection | Statistical outlier detection; inliers continue, outliers route to reject |
| Referential Integrity | Orphan check across two inputs: rows whose key is absent from the reference route to reject |
| Reconciliation | Source-versus-target report, for proving a load matches what it came from |
| Record Linkage | Fuzzy linkage across two inputs, matching records that are not identical |
| Match Grouping | Turns matched record pairs into stable clusters, so a chain of matches becomes one group |
| Survivorship | Collapses duplicates sharing a group key into a single surviving record |
| Mask / Anonymize | Irreversibly masks or anonymizes selected columns in place |
| Column Classification | Heuristic column classification and PII tagging. No LLM, no data leaves the machine |
| Advanced Column Profile | A richer single-column profile than Describe |
| Reproducible Sample | A repeatable random sample of the upstream rows |
| Validate Geometry | Flags invalid geometries with ST_IsValid |
| Repair Geometry | Replaces the geometry column in place with a repaired one |
| Check Empty Geometry | Flags empty geometries with ST_IsEmpty |
| Address Cleanse | Address parsing / normalization (planned - needs external lib) |
7 ways to drop into code available today.
| Capability | What it does |
|---|---|
| Inline SQL | Write a SELECT; the upstream node is exposed as input, result runs as a real materialized stage. A raw SQL mode runs verbatim SQL (a leading WITH / multiple CTEs / UNIONs) with no input-CTE wrapper |
| SQL Template | Parameterized SQL with ${context.var} substitution |
| SQL Routines | Reusable, named SQL saved in the workspace |
| dbt | Run a dbt project (or one inline model) as a node, against the pipeline's DuckDB. Wire several upstream sources in and the project reads them all via dbt sources, so one project models across Postgres, MySQL, files, and lakes at once. Powered by the dbt Fusion engine, fetched free at first launch (Apache dbt-core fallback); no Python setup. |
| Shell | Run any shell command; emits {stdout, stderr, exit_code, duration_ms}. Platform-aware default shell. Optional timeoutMs kills the child. |
| WebAssembly UDF | Per-row WASM transform via pure-Rust wasmi. Sandboxed (no fs / net / env). Works with any WASM toolchain (Rust, AssemblyScript, C, TinyGo). |
| JavaScript UDF | Per-row JS transform via pure-Rust boa interpreter. Sandboxed. Define a transform(row) function. |
| Python / Rust UDFs | Embedded-language stages |
73 sinks available today.
| Group | Connectors | Status |
|---|---|---|
| Files | CSV, TSV, Parquet (ZSTD), JSON, JSONL / NDJSON, Excel (.xlsx), YAML, TOML, XML (configurable wrappers), Avro (schema inferred from first row). Parquet + CSV support Hive-partitioned writes | Available |
| Geospatial files | GeoJSON, GeoPackage, Shapefile, KML, GPX via GDAL | Available (lazy-loaded) |
| Lakehouse | Apache Iceberg (full table layout), DuckLake - modes: overwrite, append, truncate, upsert (set-based delete-by-key + re-insert), merge (partial-column MERGE INTO that preserves columns the source omits) with optional CDC delete propagation, plus publish groups - several DuckLake sinks sharing a group name commit as one snapshot, so readers see all of their tables update together or none of them, and a run that cannot honour the group is refused rather than publishing part of it | Available |
| Embedded databases | SQLite, DuckDB - modes: overwrite, append, upsert (set-based delete-by-key + re-insert, no PK required), merge (partial-column MERGE INTO that preserves columns the source omits) with optional CDC delete propagation | Available |
| Network relational DBs | PostgreSQL, MySQL, MariaDB, CockroachDB - modes: overwrite, append, truncate, upsert (ON CONFLICT / ON DUPLICATE KEY) with optional CDC delete propagation | Available (live CI for PG + MySQL) |
| Network relational DBs | SQL Server / Azure Synapse (TDS, multi-row VALUES batched; auto-creates the table if absent; upsert via MERGE), Oracle (Instant Client; INSERT ALL, batched per statement; auto-creates the table if absent; upsert via MERGE), ClickHouse (HTTP JSONEachRow; upsert by pointing at a ReplacingMergeTree target table), IBM DB2 (ODBC; auto-creates the table, booleans as SMALLINT 1/0 so DB2 for z/OS also accepts them), Turso / libSQL (HTTP pipeline API; auto-creates the table, values sent as bound parameters) - every MERGE sink supports CDC delete propagation (a delete-flag column removes matched rows) | Available (SQL Server + Oracle + MySQL upsert and delete propagation verified live in Docker) |
| Network relational DBs | generic JDBC | Planned |
| Object storage | S3, GCS, Azure Blob via DuckDB httpfs (MinIO / R2 / B2 via endpoint) | Available |
| Hugging Face | Push to a Hugging Face Hub dataset repo (snk.huggingface): the upstream is materialized to Parquet and committed over the Hub API (create-repo → preupload → git-LFS → commit); write token required, repo auto-created (public or private) | Available |
| Cloud warehouses | MotherDuck, Snowflake (PAT or JWT RS256; upsert + delete propagation via MERGE), BigQuery, Redshift, Databricks SQL (upsert + delete propagation via MERGE), Azure Synapse, Teradata (ODBC), DuckDB Quack (concurrent writers to remote DuckDB via the May 2026 protocol) | Available (Snowflake MERGE verified live against the SQL-API emulator) |
| HTTP APIs | REST (POST/PUT/PATCH batched JSON-array; configurable API-key auth header name), Webhook (one POST per row), GraphQL mutations | Available |
| SaaS / CRM | Salesforce (snk.salesforce) - sObject Collections API: insert / update / upsert (by external Id) / delete, ≤200 records/request, Bearer token or OAuth 2.0 client-credentials (fresh token minted per run, same auth as src.salesforce). Salesforce Bulk (snk.salesforce.bulk) - Bulk API 2.0 for migration-scale loads: insert / update / upsert / delete / hardDelete, DuckDB streams to CSV and each ≤90 MB part runs as an async job | Available |
| Email (SMTP) | Per-row SMTP send via pure-Rust lettre + rustls. Plain text v1; HTML + attachments follow. | Available |
| NoSQL | Neo4j (rows as nodes over the HTTP Query API; one UNWIND $rows round trip per batch, mergeKeys switches CREATE to MERGE so re-runs update rather than duplicate), MongoDB (insert_many batched; upsert via replace_one on a key, plus delete propagation via delete_one), Cassandra / ScyllaDB (CQL), Elasticsearch / OpenSearch (_bulk NDJSON), Redis (pipelined SET) | Available |
| NoSQL | DynamoDB | Planned |
| Streaming | Kafka / Redpanda (rskafka), NATS JetStream, GCP Pub/Sub (REST + OAuth2), RabbitMQ (lapin), WebSocket (ws:// / wss://) | Available |
| Streaming | Pulsar, Kinesis | Planned |
| Vector / AI databases | pgvector, Pinecone (/vectors/upsert), Qdrant (/points PUT), Weaviate (/v1/batch/objects), Milvus (/v1/vector/insert) | Available |
| Vector / AI databases | Chroma, LanceDB | Preview (need vendor SDK) |
Database sinks support an optional dead-letter (validate-before-insert) step: rows that do not match the declared column types are split off to a dead-letter file (parquet / csv / json) and only the clean rows are inserted.
18 control-flow components available today.
| Component | What it does |
|---|---|
| Replicate / Tee | Send the same data to multiple downstream outputs |
| Merge Streams | Concatenate multiple input streams (UNION ALL) |
| Switch / Conditional Split | Route rows to case_1..N outputs by boolean (first match wins); default for unmatched |
| Wait / Delay | Sleep N ms / s / min / h before passing rows through |
| Throttle | Inter-stage delay derived from a rows-per-second target |
| Set Run Variable | Work out a value while the run is under way and let later steps read it as ${name} (ctl.setvar), in this pipeline and in the jobs it goes on to run. Wired to rows the expression is read against them and the first row decides, so use an aggregate to read the whole input; wired to nothing it stands on its own. The value is held in the run's own database, so it survives whichever way the engine executes the stages |
| Checkpoint | Pass rows through and also write a parquet snapshot to a path |
| Dead Letter Queue | Terminal sink for rejected rows (JSON / CSV / Parquet) |
| Run Pipeline | Inline-execute another pipeline file (ctl.runpipeline) |
| Run Job | Call a child pipeline (picked from the workspace) passing parent context variables; chain several to build a Master Job (ctl.runjob). The child runs for its side effects: it gets its own temporary database and its output is not composed back into the parent, so a child cannot yet return rows to its caller |
| Parallelize | Run the downstream branches wired to its outputs concurrently; branches are unlimited (ctl.parallelize) |
| Iterate | Run a sub-pipeline N times with ${ITER_INDEX} substitution |
| For Each | Run a sub-pipeline once per input row with ${ITER_ITEM_<FIELD>} substitution; an optional item key column names each run so per-row watermarks stay separate |
| Try / Catch | Install a fallback sub-pipeline if the wrapped stage fails |
| Retry | Per-stage retry policy (configure on Advanced tab) |
| Log Message | Emit an info log line ({rows} = upstream count), pass rows through (ctl.log) |
| Warn | Emit a warning log line, pass rows through (ctl.warn) |
| Die / Fail | Stop the run with a message: always, only when the input has rows, or only when empty (ctl.die) |
| Schedule | Cron / interval / file-watch triggers via the orchestration crate |
A run variable is read as a value wherever the SQL of a later step names it: on its
own, as a whole string literal ('${name}', the usual way to write a value into a
WHERE clause, where the quotes come off with it), or inside a longer literal, which
is joined around it. A name a node sets this way is left for the run to fill in, so a
static context entry of the same name does not pre-empt it.
It also travels into whatever the pipeline runs. A Run Job, an Iterate or a For Each
started after the value was worked out hands it to the child as ${name}, and on again
to whatever that child runs, so a value settled in a master job reaches a body several
levels down. A value named on the call itself still wins, since naming one there is how
a parent says which value to run the child with. A name whose value came out NULL is
not passed: it has no value, and an unset ${...} is left as it is rather than arriving
as the word NULL.
A sub-pipeline runs under its own name, so its run log lands in
logs/<child>/ and an xf.incremental watermark inside it is saved to
state/<child>/<node>.json. Two different children driven by the same For Each
therefore keep separate marks.
Set For Each -> Item key column to separate the ITERATIONS too. The child
then runs as <child>@<value>, so loading 400 tables through one sub-pipeline
keeps 400 watermarks in state/<child>@<table>/<node>.json instead of one.
Leave it blank and every row shares a single mark, which silently skips rows
when each row is a different table. It is never inferred from the row's
position, because that would move every watermark the moment the driving query
is reordered.
Set For Each -> Dispatch to Queue for workers and the rows are written to
batches/<id>.ndjson instead of being run, one JSON line per row carrying the
child reference and that row's substitutions. Nothing runs until a worker picks
the batch up, so the run that queued it reports how many items are waiting
rather than pretending they loaded. A batch is a file in the workspace like
everything else here: no queue server, no database, no network service.
Queueing also reports whether the items are actually safe to spread out. Both "400 items each loading their own table" and "400 items appending to one file" look identical on the canvas - one sink node with a variable in the path - and only the first survives being run at once. So each item's variables are put into the child and the resulting targets are named with the same function that builds the workspace catalog, before anything picks the batch up:
duckle: 400 item(s) write to targets nothing else in the batch writes, so they
are safe to run at the same time
duckle: heads up - 1 target(s) are written by more than one item (400 items
write /lake/everything.parquet). Workers run items at the same time, so
these will collide unless the sink is an upsert or the target is
append-safe
It warns rather than refuses: appending many items into one table is a real thing to want, and only you know whether that sink is safe for it. Items whose child cannot be read are counted and reported, so a partial check never reads as a clean one.
Then run workers against it:
duckle-runner work --workspace /path/to/workspace # drain every batch
duckle-runner work --batch fe-20260816T101112123 # just this one
duckle-runner work --once # one item, then exit
duckle-runner work status # what is stuck, and why
duckle-runner work retry --dead # start the stuck ones over
Start it on several machines pointed at one workspace and they share the batch.
Each item is claimed with the same OS lock a pipeline run uses, so no two
workers take the same one, and a worker that is killed mid-item leaves nothing
to clean up: the kernel drops the lock and the item becomes claimable again.
There is no lease, no heartbeat and no timeout, because there is nothing to
expire. Progress is appended to batches/<id>.ledger.ndjson, so re-running a
worker resumes rather than repeats.
Retries are bounded. A failed item stays claimable and is tried again on a
later pass, which is right for a timeout and wrong for a 404 that will always be
a 404: without a limit that item takes a worker slot on every pass forever. Set
Max attempts per item on the For Each node, with a fixed or exponential
backoff, and an item that uses them up is left alone and reported as dead rather
than chased. work status lists what is waiting out a backoff and what is dead,
with the last error; work retry --dead starts the dead ones over. A retry
appends a reset marker rather than rewriting the ledger, so the failures stay
readable - an item that died four times before someone fixed the source still
says so. Leave max attempts at 0 and behaviour is exactly what it was.
Items run at least once, not exactly once. The ledger is written after an item succeeds, so a worker that finishes an item and then dies leaves it looking undone and another worker repeats it. That is the honest trade for having no transactional store - the alternative loses items instead of repeating them, and a lost load is worse. Make the child idempotent (an upsert sink rather than an append) and a repeat costs time, not correctness. A failed item stays claimable and is retried on a later pass, with the failure kept in the ledger.
The console has a Batches view: progress per batch, how many items are running right now, how many failed, and the recent attempts with the worker that ran each one. "Running" is answered by asking the run lock rather than by trusting a heartbeat, so a worker that died is not counted as running and there is no lease that could have gone stale. Retry failed clears the recorded failures so those items are claimable again, keeping the successes so a retry never repeats finished work.
Before running anything, a worker proves the lock actually excludes on that
filesystem: it takes a lock and asks a second process whether it can take the
same one. Some shared filesystems tell every caller it has the lock - NFS with
no lock daemon is the classic case - and on one of those every worker would
claim every item and each item would run once per worker, silently, with no
error anywhere. A worker refuses to start there. Check it yourself with
duckle-runner work --check; --no-check overrides, knowing the above. A test
that could not be run is only a warning, because failing to prove exclusion
is not the same as having disproved it.
Measured on one machine: three workers against a twelve-item batch took four
items each, with no item run twice. Several machines against one shared
filesystem is the design intent and is not yet measured, so treat it as
untested until it is. scripts/measure-multi-host-batch.sh is the measurement:
point it at a shared workspace and two or more hosts and it counts duplicate
executions, failing if there are any.
Every node has an Advanced tab with fields the engine honours at run time:
| Field | What it does |
|---|---|
| Retry attempts | Total tries on failure (1 = no retry). Sleeps backoff * attempt ms between attempts. |
| Retry backoff (ms) | Inter-attempt sleep, linearly scaled by attempt index. |
| Memory limit (MB) | PRAGMA memory_limit applied to this stage only. |
| Log row count | Print the post-stage rowcount to the run output. |
| Capability | What it does |
|---|---|
| Run feedback | Streaming run events light nodes up stage by stage, with per-node row counts, real mid-query cancel, and run history. |
| Error traceback | A failed stage reports the exact compiled SQL plus the underlying DuckDB message, in both the Run view and the NDJSON run log, so any component's failure is debuggable. |
| Column lineage | A top-bar Lineage button shows, per node, each output column traced back to the source column(s) it derives from. |
| Dives + dashboards | Live-querying, shareable data views that run where your data already is, stitched into multi-chart dashboards. Generate a chart from a plain-language question, export a dive to a self-contained HTML file, open standalone /dive/<id> and /dash/<id> share pages, and find everything in the top-bar Dives gallery. |
| Artifacts | src.artifact gives one row per file described the way a pipeline can reason about it - uri, name, media_type, size_bytes, sha256, modified_at - for PDFs, images, archives, OCR output and model binaries. An artifact is a reference, not the bytes, so it joins, filters and iterates like any other table. Hashing is off by default because it reads every byte |
| Python, row or table | code.python takes process(row) for a row at a time, or transform(table) to be handed the whole table as a pyarrow Table - for polars/pandas work, OCR, entity resolution or ML. The table path goes through Parquet rather than JSON: measured 2.11s -> 0.74s on 200k rows, and it keeps types, where the row path turns every timestamp into a string. Needs pyarrow only when transform is used |
| A workspace's own Python | A Python stage is only reproducible if the packages it needs travel with the pipeline rather than being whatever the machine happens to have. Put a virtual environment at .venv in the workspace - uv venv && uv pip install pyarrow polars, or the stdlib python -m venv - and code.python uses that interpreter on every machine, laptop, CI and headless runner alike. Nothing is installed at run time, so an air-gapped box stays air-gapped, and DUCKLE_PYTHON_BIN still overrides everything |
| Batch inference that survives a rate limit | xf.ai.llm, xf.ai.classify and xf.ai.embed take Parallel requests and Retries on rate limit. A 429 or 5xx is now retried per request, honouring Retry-After, instead of failing the stage: before this, one rate limit at row 400,000 threw away the 399,999 rows already paid for, because the only retry in the engine re-runs a whole stage from row 0. Requests run up to Parallel requests in flight and results are written back by index, so the output row order is still the input row order. Both default to today's behaviour (1 in flight, 3 retries). xf.ai.llm also finally sends Max tokens, a field the panel has offered since v0.5.4 while the request never carried it |
| Blocking for entity resolution | Every fuzzy match compares pairs, and comparing all of them grows with the product of the row counts, so linking 100k records against 100k is 10 billion comparisons. qa.block proposes only the pairs worth comparing: named rules of columns that must be equal (same postcode, same surname initial), each one pass, a pair caught by several rules still emitted once. One input dedupes within a table, the lookup port links two. It emits id_a, id_b, blocking_rule and a_<col>/b_<col> for carried columns, which is exactly what qa.matchgroup reads by default, so blocking, comparison with xf.addcol, banding with ctl.switch and clustering chain up out of components that already exist |
| One REST node per parent row | Real APIs are rarely one endpoint: /companies gives you ids, and the data you want is at /companies/{id}/officers. Give src.rest a URL per upstream row and wire a parent into its input, and it makes one request per row, substituting {column} from that row, unioning every result into its one output table. The shared connection, the single OAuth mint, the auth headers and all five pagination strategies are reused per request rather than re-done. Carry upstream column stamps the parent's key onto each child row so the two can be joined back together, and chaining three nodes main-to-main gives three real relations rather than one opaque loop. Unwired, the node is the plain source it has always been |
| Runs that outlive the request | A backfill can run for hours, and a synchronous HTTP call is the wrong place to keep it: clients, proxies and load balancers all time out while the pipeline is still legitimately working. POST /api/run/async answers 202 with a runId straight away; GET /api/run/status?runId= reports queued, running or finished with the pipeline's own status; DELETE /api/run?runId= cancels, which is polled at every stage boundary and kills the active DuckDB child so even a long query stops promptly. Every run record now carries the id it was accepted under, so a console that restarted mid-run can still answer for it. POST /api/run is unchanged for anything that wants to wait |
| HTML as a source | A great deal of public data is published only as HTML: registries, filing pages, results tables. src.html reads a local file or an http(s) URL and turns it into rows by CSS selector. Name a column per sub-selector (a@href reads an attribute), or leave the columns empty and let a table be a table: the th cells name the columns and each tr is a row. Parsed with a tolerant HTML parser, so the unclosed tags and unquoted attributes real pages carry - and that the strict XML reader rejects outright - are fine. A selector that does not parse fails the run naming it, rather than quietly producing a table of nulls |
| HTTP transport, set once | Proxies, timeouts and a User-Agent are transport, not credentials, and every HTTP-backed component wants the same ones. A saved HTTP transport connection carries a proxy, a read timeout, a connect timeout and a User-Agent, and src.rest and src.html reference it alongside their auth connection, so a corporate proxy is one edit rather than one per node. What a node sets itself still wins. Every request in the engine also now has deadlines: a connect timeout of 30s and a read timeout of 300s, both overridable with DUCKLE_HTTP_CONNECT_TIMEOUT and DUCKLE_HTTP_READ_TIMEOUT. They are per-read, not per-transfer, so streaming a large file is unaffected while a dead socket can no longer park a stage indefinitely - which matters more now that AI stages keep several requests in flight |
| PDF pages as rows | A great deal of data engineering starts from documents, not tables: filings, annual accounts, invoices, regulatory publications. src.pdf gives one row per page - document_id, page_number, text, has_text_layer, width, height and the document's own metadata - from a file or a whole folder, using the text layer the PDF already carries. document_id is the same value src.artifact puts in uri, so a file listing and its pages join without translation. There is no OCR, deliberately: rasterising a scanned page needs a native rendering engine and per-language trained data, which would end the self-contained cross-OS build. A scanned page arrives with has_text_layer false instead, which is what lets you filter those pages out and route them to whatever OCR you already run |
| Model cards, not a model store | Once a pipeline can train a model it needs to answer which model produced this output, and where it lives. snk.model records a card - the artifact URI your training script wrote, plus whatever metrics, framework and hashes it reported - to <folder>/<name>/<version>.json, with a latest.json pointer beside it; src.model reads one back as a row, addressed as name@version or name@latest. The engine never touches the model bytes and never loads a model: the row carries the URI and your Python stage does the rest. What it does add is the part a convention cannot - the card is written only if the whole run succeeded, so a training pipeline that fails afterwards never registers a model and a failed retrain never moves the pointer off the model that still works |
| Kafka security that is actually applied | The Kafka form has offered a security protocol, a SASL mechanism, a username and a password since the connector shipped, and the engine read none of them: a node configured for SASL_SSL connected in plaintext, unauthenticated, and said nothing about it. All four are now honoured - TLS reuses the same merged OS-plus-bundled trust store every other connection uses, and PLAIN, SCRAM-SHA-256 and SCRAM-SHA-512 are supported. A mechanism outside that set fails the run naming what is available, rather than quietly downgrading to an unauthenticated connection. Consumer group has been removed: the Kafka client Duckle uses implements no consumer groups, so it could never have done anything - use Resume where the last run stopped instead, which is the job it looked like it was doing |
| Kafka that resumes | Tick Resume where the last run stopped on a Kafka source and it remembers the offset it reached, carrying on from there next run. That is what turns a schedule into a stream: without it, Earliest re-reads the whole backlog every run and Latest skips everything that arrived in between, so repeated runs could never be stitched together. The position is written only when the whole run succeeded, so a failure after the read re-delivers those records rather than losing them - at-least-once, deliberately, since the alternative is committing an offset for rows no sink ever wrote. A saved position records the topic and partition it belongs to and is ignored if either changes |
| Response provenance | Tick Add response metadata on a REST source and every row carries _http_url (the exact URL fetched, per page), _http_status and _fetched_at, so you can tell whether a result changed because the source changed or because the parser did |
| Reuse a stage's output | Tick Reuse this stage's output on an expensive deterministic stage and it writes its rows once, then reads them back while its SQL, everything above it, and the size/modified time of any local file it reads are unchanged. Off by default and per stage: a cache that guesses when it is still valid serves stale rows silently. rm -r .duckle/duckle_cache clears it |
| Pipeline tests | duckle test runs a pipeline against a fixed input and asserts the rows out of one node. validate catches what will not compile; this catches a transform that compiles and computes the wrong thing. A case stops at the node it asserts on, so no sink writes. Exit 1 on a failed assertion, so CI gates on it |
| Run to a node | duckle-runner --target <node> stops at that node and prints its rows; the MCP run_pipeline tool takes the same target. Nothing downstream runs, so no sink past it writes - the run-from-here the desktop preview uses, for checking one step without executing the rest |
| Run logs | Every run writes component-level NDJSON to <workspace>/logs/<pipeline name>/runtime.log (start/finish per stage, row counts, durations, ctl.log / ctl.warn / ctl.die messages). Tail it straight into Splunk or Dynatrace. |
| Schedules | Cron, fixed-interval, and file-watch triggers, driven by an in-process scheduler. |
| Context variables | Per-environment variables; bind any field to one via a Manual / Context dropdown, or reference ${var} inline. Resolved at run time. |
| Workspace-relative paths | Built-in ${workspace} (alias ${projectroot}) resolves to the active workspace root, so source / sink paths can be written relative to it and a workspace folder stays portable when copied or moved. No context needed; works in the canvas, schema autodetect, and headless runs. |
| Run-time path placeholders | Built-in ${date}, ${time}, ${datetime}, ${timestamp}, and ${now} (UTC) stamp the current run time into any path. They resolve fresh on every run (canvas, schedule, headless runner, built bundle), and a sink's parent folder is created automatically, so a path like ${workspace}/exports/${date}/orders.parquet lands in a new dated folder each day. No context needed. |
| Cloud credentials | Saved S3 / GCS / Azure connections become DuckDB SECRETs; cloud reads / writes go through httpfs. S3-compatible endpoints (MinIO / R2 / B2) supported via ENDPOINT + URL_STYLE. |
| Workspace | Pipelines, connections, contexts, documents, and routines persist as plain JSON and Markdown files in a folder you choose. |
Models inherit the quality of their inputs. RAG indexes, embedding stores, and training sets quietly accumulate duplicates, nulls, malformed rows, mixed encodings, and inconsistent schemas. Duckle is built to scrub that data before it lands in a vector store:
xf.ai.dedupe over a precomputed embedding columnxf.ai.pii before embeddingxf.ai.chunk -> xf.ai.embed for RAG indexingxf.ai.classify constrains the model to one of N user-supplied categories)Duckle ships a thin shell and installs its engines on first launch.
| Engine | Role | Status |
|---|---|---|
| DuckDB | Default execution engine: analytics, file formats, cloud reads, SQL pushdown. Tracking v1.5.3 (latest stable). A lock-free single-SELECT read (Engine::query) powers dives. | Working |
| Duckie AI Assistant | Local chat assistant via llama.cpp + Qwen 2.5 Coder 1.5B GGUF. Downloads ~1.1 GB and needs no network once installed, or point it at your own OpenAI-compatible endpoint and skip the download entirely. Managed as a llama-server subprocess exposing an OpenAI-compatible API on 127.0.0.1. | Installable |
| SlothDB | Alternate embedded analytical engine (SouravRoy-ETL/slothdb), installed the same way and selectable per pipeline. | Installable |
| Native | In-process Rust streaming / incremental engine. | Planned |
When the installer downloads the DuckDB CLI it also pre-fetches the extensions Duckle uses, with per-extension progress, so the first time you touch a Postgres source or an Iceberg table there is no surprise network hop mid-pipeline:
httpfs (S3 / GCS / HTTP), azure (Azure Blob native), sqlite, postgres, mysql, excel, iceberg, delta, ducklake, vss, fts.
spatial is lazy-loaded (~50 MB GDAL bundle) - it installs on first use of a geospatial source/sink to keep the initial download small.
A wider tour of the workflow.
| Step | What you do | Where to look |
|---|---|---|
| 1. Sources | Drag a source, point it at a file / DB / cloud URL / SaaS endpoint. Click Autodetect schema to read columns + a sample. | Sources reference |
| 2. Transforms | Wire transforms to source output ports. Configure in the Properties panel. Preview tab shows live rows; Plan tab shows generated SQL. | Transforms reference |
| 3. Data quality | Drop in a validator (Not-Null, Range, Regex, Uniqueness). Passing rows continue on the main port; failures route to the reject port. | Data quality reference |
| 4. Sinks | Finish with a sink (file, DB, cloud, vector DB, message bus, email). Set write mode (overwrite, append, truncate, upsert). | Sinks reference |
| 5. Run | Press Run to execute on DuckDB. Nodes light up stage by stage; Output + Console show row counts, timing, errors. Stop button kills mid-run. | Run feedback |
| 6. Ask Duckie | For anything you can describe in English, the AI assistant can sketch a pipeline. Iterate by editing the graph or asking follow-ups. | Meet Duckie |
| 7. Reuse | Save Connections, Context variables, and SQL Routines in the workspace; reference ${context.var} in any field. Everything persists as plain files. | Workspace and Git flow |
| 8. Schedule | Attach a cron, interval, or file-watch trigger to run a pipeline automatically. | Schedules and triggers |
Ready-to-adapt patterns. Each one is a few nodes you wire on the canvas (or ask Duckie to sketch).
"Read orders.csv, drop nulls, deduplicate by order_id, write to orders_clean.parquet"
src.csv -> qa.not_null -> qa.uniqueness -> snk.parquet
Set qa.not_null to the columns that must be present; set qa.uniqueness to order_id. Rejected rows go to a snk.csv on the reject port for inspection.
"Read all rows from Postgres
events, upsert into Snowflake tableanalytics.eventsonevent_id"
src.postgres -> snk.snowflake (mode=upsert, conflict=event_id)
Attach a ctl.schedule with cron 0 2 * * * to run nightly at 02:00.
"Read all .json.gz files in
s3://logs/2026/*/*.json.gz, parse, write Hive-partitioned byevent_date"
src.s3 (glob, autodetect json.gz)
-> xf.derive (event_date = CAST(ts AS DATE))
-> snk.parquet (path=out/, partitionBy=event_date, mode=overwrite_or_ignore)
"Chunk our docs, embed with OpenAI, dedupe near-identicals, store in pgvector"
src.s3 (markdown files)
-> xf.ai.chunk (chunkSize=1500, overlap=150)
-> xf.ai.pii (redact)
-> xf.ai.embed (model=text-embedding-3-small, baseUrl=https://api.openai.com)
-> xf.ai.dedupe (threshold=0.95)
-> snk.pgvector (table=docs)
"Pull yesterday's Slack messages from #support, classify by sentiment, email a summary"
src.slack (channels.history with oldest=yesterday)
-> xf.ai.classify (categories=positive,negative,neutral)
-> xf.aggregate (group by sentiment, count)
-> snk.email (to=oncall@..., subject=Daily Support Digest)
"Receive 100 webhooks, archive each one as JSON in S3"
src.webhook (port=8080, maxRequests=100, timeoutMs=300000)
-> snk.s3 (path=s3://archive/events/, format=jsonl, partitionBy=event_date)
"Build a dashboard of who's been committing what in the last 30 days"
src.git (mode=log, maxRows=10000)
-> xf.filter (date > current_date - INTERVAL '30 days')
-> xf.aggregate (group by author_email, count)
-> snk.csv (path=author-stats.csv)
More examples live in samples/ - drop the pipeline files into a workspace and open them.
Push, pull, branch, and watch CI from inside Duckle. No terminal required.
Click the Git icon in the topbar to open the workspace Git panel. Built-in integration with GitHub and GitLab, on the system git CLI (no FFI, no embedded git library):
| Feature | What it does |
|---|---|
| Status snapshot | Current branch, ahead/behind counts, list of modified / staged / untracked / conflicted files |
| Stage all + commit | One-click git add -A && git commit -m "..." with your message |
| Push / Pull | git push and git pull --ff-only against origin. The button stays disabled when there's nothing to push |
| Branch list, switch, create | Lists local branches; click to switch; create new branches inline |
| Remote URL config | Add or change origin URL from inside the panel - auto-detects GitHub vs GitLab from the host |
| PAT-prompt fallback | First tries git push using your system credential helper (GitHub CLI, osxkeychain, manager-core). On a 401, prompts for a Personal Access Token, saves it AES-encrypted in <workspace>/.duckle/secrets/git.json (auto-gitignored), retries with the token injected into the HTTPS URL |
| CI build badge in topbar | Polls GitHub Actions or GitLab CI every 30 s for the latest pipeline on your current branch. Shows green / red / yellow / gray. Click to open the build in your browser |
Workflow. Workspaces are plain folders (see Workspace and Git flow) - any standard Git workflow works:
Create / clone -> open in Duckle -> edit pipelines -> commit + push ->
PR / MR -> CI runs your pipeline tests -> merge -> pull
You can do the entire push / pull / merge loop without leaving Duckle. Heavy operations (interactive rebase, conflict resolution, log archaeology) still live in your terminal or external Git tool - the panel is designed for the everyday flow, not as a full Git replacement.
Provider detection. The remote URL host determines which CI API the badge polls:
| Provider | CI source | API |
|---|---|---|
github.com | GitHub Actions | GET /repos/{owner}/{repo}/actions/runs |
gitlab.com or self-hosted GitLab | GitLab CI | GET /api/v4/projects/{id}/pipelines |
| Other / bitbucket | (no CI badge for now) | - |
The badge uses the same PAT you saved for pushes - no separate auth step.
A workspace is a folder you pick on first launch. Everything you build lives there as plain text:
my-workspace/
pipelines/
orders_etl.pipeline.json # the node graph
nightly_load.pipeline.json
connections/
prod-postgres.connection.json # saved DB credentials (encrypted)
snowflake-analytics.connection.json
contexts/
dev.context.json # variables for dev environment
prod.context.json
routines/
cleanse-addresses.sql # reusable SQL snippets
documents/
runbook.md # plain-Markdown docs
schedules.json # all scheduled runs in this workspace
run-history/
orders_etl/ # one folder per pipeline
2026-05-25T14-30-00.json # one file per run
Git-friendly by design. Every file is human-readable JSON or Markdown. Standard workflows work:
git init my-workspace && cd my-workspace
git add . && git commit -m "Initial pipelines"
# Pull a teammate's update
git pull --rebase
# Push your changes
git push
# Branch for a risky migration
git checkout -b feature/upsert-mode
# ...edit pipelines in Duckle...
git diff # readable JSON diffs
git push -u origin feature/upsert-mode
# open PR / MR
Sensitive values in connections get encrypted with a workspace-local key (workspace/.duckle/keys/). Don't commit that file - add **/.duckle/keys/ to .gitignore. The connection JSON files themselves only hold the ciphertext, which is safe.
Duckle ships its own Model Context Protocol server, so Claude (or any MCP client - Claude Desktop, Claude Code, Cursor, or any other LLM agent) can drive Duckle directly: browse the full component catalog and per-component property schemas, generate a pipeline straight into a working directory you choose, validate it (compile without running), run it headlessly, read existing pipelines and their run logs, build a standalone artifact, and manage saved connections.
If you have uv, one line connects any MCP client. Nothing is installed, no engine to configure: uv fetches the package and the DuckDB engine into a throwaway environment and the server finds it there.
claude mcp add duckle -- uvx duckle mcp
For Claude Desktop, Cursor, or any other client, the same thing as config:
{ "mcpServers": { "duckle": { "command": "uvx", "args": ["duckle", "mcp"] } } }
uvx duckle mcp works because the package and the command are both named duckle, so there is no --from to remember. If you would rather install it, pip install duckle puts duckle on PATH and the same duckle mcp command applies.
Then ask the agent something like "use duckle to list the available components". It can discover a real connector rather than guess one, compile-check a pipeline with validate_pipeline before anything executes, run it, and hand back column-level lineage. What it produces is the same JSON the canvas opens, so you can see what it built.
The MCP server is also bundled inside the app - there is nothing extra to install. In the designer, click Connect to Claude in the top bar to open the connector popup, then pick your client:
duckle server for you (runs
claude mcp add under the hood).duckle entry into
that client's config, with the resolved engine paths filled in (both the
Microsoft Store / MSIX and standalone Claude Desktop layouts are handled).Restart the AI client, then try "Use duckle to list the available components" to confirm the connection.
For a build-from-source or server setup, point any client at the duckle-mcp
binary directly. It speaks JSON-RPC over stdio and reuses the DuckDB engine
in-process (no GUI, no Node runtime).
cargo build -p duckle-mcp --release # target/release/duckle-mcp
claude mcp add duckle -- /path/to/duckle-mcp
For Claude Desktop and other clients, add it to mcpServers:
{
"mcpServers": {
"duckle": {
"command": "/path/to/duckle-mcp",
"env": {
"DUCKLE_DUCKDB_BIN": "/path/to/duckdb",
"DUCKLE_RUNNER_BIN": "/path/to/duckle-runner"
}
}
}
}
Tools: list_components, get_component_schema, create_pipeline,
validate_pipeline, run_pipeline, list_pipelines, read_pipeline,
read_run_logs, build_pipeline, list_connections, create_connection, backfill_list, backfill_set, backfill_clear.
run_pipeline / build_pipeline need a DuckDB binary (DUCKLE_DUCKDB_BIN);
build_pipeline also needs duckle-runner (DUCKLE_RUNNER_BIN). Full guide:
docs/current/mcp.md.
Saved connections become DuckDB secrets at runtime so credentials never leak into the pipeline JSON.
| Type | Stored fields | Used by |
|---|---|---|
| PostgreSQL / MySQL / etc. | host, port, user, password, database, ssl mode | src.postgres, snk.postgres, ... |
| Snowflake | account, user, role, warehouse, PAT or JWT private key | src.snowflake, snk.snowflake |
| S3 / GCS / Azure | access key, secret, region (or service-account JSON) | All cloud sources/sinks via httpfs |
| MotherDuck / Databricks / BigQuery | token, workspace URL | Respective sources/sinks |
| Generic REST / SaaS | base URL, headers, auth scheme (Bearer / Basic) and token | All REST aliases |
Connections live in workspace/connections/ as JSON. The token/password field is encrypted with the workspace key; the rest is plain text.
To use a connection in a pipeline, the Properties panel of any compatible source/sink shows a Connection dropdown - pick one and the fields auto-fill. The list is filtered to connections of a matching kind, so a REST connection is not offered on a JDBC node.
A REST connection is the exception to auto-fill, because it exists to be shared by many nodes that each send a different request: put the vendor's headers and token on the connection once, and rotating a key is a single edit. Headers are merged per key at run time, and the node wins on a key it sets itself; the node's own url and request body are never overwritten. A node with no URL of its own inherits the connection's.
The Copy SQL / Export SQL output is display-only and never executed. Secret values (passwords, tokens, keys, connection strings) are replaced with named placeholders such as ${DUCKLE_PASSWORD}, so the exported script stays valid and is safe to share - substitute the real value at run time. To emit the real credentials instead (so the script runs unchanged), set the environment variable DUCKLE_EXPORT_INCLUDE_SECRETS=1; the output then contains live secrets and should be handled accordingly.
Bind any field to a context variable that resolves at run time. Useful for dev vs prod, per-environment paths, secrets injected from CI, etc.
In a context file (workspace/contexts/prod.context.json):
{
"name": "prod",
"vars": {
"DB_HOST": "db.internal.acme.com",
"S3_BUCKET": "acme-prod-data",
"BATCH_SIZE": "10000"
}
}
In the Properties panel of any node, switch a field from Manual to Context and pick DB_HOST. Or inline-reference one with ${DB_HOST} in a string field.
Pick the active context from the topbar's Context dropdown. Switch contexts and re-run without editing the pipeline.
Prerequisites
cargo-tauri CLI: cargo install tauri-cli --version "^2"Clone and install
git clone https://github.com/slothflowlabs/duckle
cd duckle
npm --prefix frontend install
Run in development (hot-reloading frontend plus the native shell):
cargo tauri dev
Build a release binary:
# The --features custom-protocol flag is required: without it, tauri-codegen
# embeds the dev URL instead of the bundled frontend.
cargo build --release --manifest-path apps/desktop/Cargo.toml --features custom-protocol
Outputs land in target/release/duckle (or duckle.exe). The engine is not statically linked: DuckDB downloads at first launch, which is why the build is fast and the binary is tiny.
Run the tests:
cargo test # workspace unit + plan tests
DUCKLE_DUCKDB_BIN=/path/to/duckdb cargo test -p duckle-duckdb-engine # full integration suite
duckle/
apps/desktop/ Tauri 2 shell: Tauri commands, engine installer, llama runtime, window
frontend/ React 19 + Vite + TypeScript: the designer UI + chat panel
crates/
duckdb-engine/ Compiles the node graph to SQL and drives the DuckDB CLI
slothdb-engine/ SlothDB adapter
scheduler/ Cron / interval / file-watch triggers
metadata/ Schema and type model
plugin-sdk/ Connector / inspector traits
connectors/ Source and sink connectors
runtime, workflow-engine, transform-engine, stream-engine, execution-core
COPY ... TO statements; cancel kills the process. No statically linked database, so the binary stays small.llama-server subprocess on 127.0.0.1 exposing an OpenAI-compatible chat-completions API. The chat panel streams from it via SSE. The model is sandboxed: no fs, no net, no tools - it can only emit text.A few knobs you can set without touching code.
| Setting | Where | Effect |
|---|---|---|
| Theme | Topbar sun/moon toggle | Light / dark, persisted to localStorage |
| Workspace | Topbar workspace pill -> Switch | Change the folder Duckle reads/writes to |
| Active engine | Topbar engine selector | DuckDB (default) or SlothDB - per-pipeline |
| Active context | Topbar context dropdown | Switches which context variables resolve at run time |
| AI Assistant baseURL | xf.ai.llm / xf.ai.embed / xf.ai.classify props | Point at any OpenAI-compatible endpoint (default: Duckie's local llama-server) |
| Per-stage retry | Properties panel -> Advanced tab | Total attempts + linear-scaled backoff per stage |
| Per-stage memory cap | Properties panel -> Advanced tab | PRAGMA memory_limit applied just to that stage |
| Per-stage materialize | Properties panel -> Basic tab | auto, view (lazy), memory (read once, table in RAM), or disk (read once, streamed via a temp Parquet file for huge intermediates) |
| DuckDB extensions | Pre-fetched at install; lazy-loaded for spatial | See First-launch extension pre-fetch |
Env var RUST_LOG | Before launching the binary | RUST_LOG=debug duckle.exe to see verbose engine logs |
Env var DUCKLE_DUCKDB_BIN | Before running engine tests | Points the integration test suite at a DuckDB CLI |
Env var DUCKLE_CA_CERT | Before launching the binary | Path to a PEM bundle of extra CA certificates to trust (corporate proxy / private CA), added on top of the OS trust store and bundled roots |
Env var DUCKLE_HTTPS_PROXY (or standard HTTPS_PROXY / HTTP_PROXY / ALL_PROXY) | Before launching the binary | Routes REST / cloud-API connectors and the in-app updater through an HTTP proxy, e.g. http://user:pass@proxy:8080. Use the standard vars to also cover engine / model downloads |
A few patterns that consistently produce sub-second runs at small / medium data scale, and tractable runs at warehouse scale.
| Tip | Why |
|---|---|
| Use Parquet, not CSV, for intermediate steps | Columnar + compressed; DuckDB reads only the columns the next stage needs. CSV is fine for source / sink at the edges. |
| Push filters as early as possible | xf.filter early in the graph compiles to a WHERE that runs at scan time, not a post-scan filter. |
Use the vss + fts indexes | Vector + full-text search hit DuckDB extensions directly. Faster than the alternative of pulling data out and indexing in Python. |
| Avoid per-row API calls when batch APIs exist | xf.ai.embed batches up to 100 inputs per request; snk.rest defaults to one batched request. Per-row patterns (xf.ai.llm, snk.webhook) are slower by design - use them when you actually need per-row behavior. |
| Cap heavy aggregates with the per-stage memory limit | Properties panel -> Advanced -> Memory limit (MB) prevents one big GROUP BY from blowing through all of RAM. |
Use ctl.checkpoint for long-running pipelines | A checkpoint stage writes a Parquet snapshot to a path you choose, so a future run can resume from there with src.parquet. |
Disable xf.debug.log in prod | Logging rows is per-row I/O; fine for dev, costly at scale. |
| Sort once at the end, not in the middle | xf.sort is a global sort; doing it once before the sink avoids re-sorting downstream. |
Put an xf.dbt node behind its upstream, not first | When a dbt node has upstream stages, Duckle warms dbt's project parse in the background while those stages run, so dbt run reuses a warm cache instead of paying a cold parse. Set DUCKLE_DBT_PREWARM=0 to disable. |
Yes, free + open source. Dual-licensed MIT OR Apache-2.0. You can use it commercially, fork it, sell what you build with it. No usage limits, no telemetry.
It covers similar ground - moving data across 190 sources and destinations - but locally, with nothing to host and no per-row, per-connector, or per-seat billing. Pipelines are built visually or from plain English and compile to readable DuckDB SQL that runs wherever you deploy it: a laptop, a server, CI or a container. The trade-off is scope: Duckle does not split one query across a cluster, so for warehouse-scale replication you push the work down into the source system or point the output at the system that scales.
Yes. Duckle executes on the embedded DuckDB engine, so there is no vendor warehouse to buy, no vendor platform to sign up to, and no account. You run it where you choose: a server or VM you own, a container in your own AWS, Azure or GCP account, or a workstation. It needs no outbound network of its own, which suits air-gapped, on-premise and compliance-sensitive work. Pipelines still read from and write to cloud systems whenever you point them at one.
Airbyte focuses on hosted extract-and-load connectors; dbt focuses on SQL transformation; Talend is a heavyweight GUI suite (its free Open Studio edition was discontinued in early 2026). Duckle is a single open engine that does extract, transform, and load together - write it in Python, wire it from connectors, or draw it on a canvas - compiles to DuckDB SQL, and can also run dbt on DuckDB inside the same tool. One format, one engine, running on your own infrastructure rather than a vendor's, with no per-row billing.
No. Duckle makes no outbound calls of its own from wherever you run it, laptop or server. The engines (DuckDB, llama.cpp) are downloaded from official upstream releases on first launch and then run in place. The only network calls Duckle makes on your behalf are the ones your pipelines explicitly do (e.g. a src.s3 reading from your S3 bucket, or xf.ai.embed if you configure it to hit OpenAI).
Duckie needs no network once its model is downloaded - and if you would rather it did not run in-process at all, point it at your own OpenAI-compatible endpoint.
Bigger than people assume, because the ceiling is the instance you provision rather than the laptop you develop on. The engine is parallel and uses every core available, so the same pipeline that you debug against a sample on a laptop runs against the full set on a large server without changing. For reference, 96M rows come out of live Postgres to Parquet in 39.9s.
Past whatever instance you are willing to pay for, you have two routes that do not involve rewriting anything: turn on pushdown so the query executes inside the source database, or point the output at a warehouse or lakehouse that scales horizontally. What Duckle will not do is spread a single query across a cluster.
No - Duckle downloads it for you on first launch. The download is ~30 MB and includes the most-used extensions (httpfs, postgres, mysql, iceberg, delta, vss, fts, etc.) so the first time you touch a Postgres source there's no mid-pipeline network pause.
73 to 110 MB, depending on platform. As of v0.7.0: macOS 73 (x64) to 88 (arm64), Linux 74 (arm64) to 100 (x64), Windows 98 (arm64) to 110 (x64). It embeds the headless runner and the MCP server, and the headless runner on its own is 27 MB. The engines aren't statically linked - DuckDB (~50 MB with extensions) and the Duckie LLM (~1.1 GB for the Qwen GGUF) both download on first launch with a guided installer into your app-data folder, so they update independently of the app.
Yes. The AI transforms (xf.ai.embed, xf.ai.llm, xf.ai.classify) accept a baseUrl prop. Point it at any OpenAI-compatible /v1/... endpoint and an apiKey and Duckle uses that instead. The local Duckie chat panel is hardwired to localhost; the pipeline AI transforms are configurable.
In the workspace folder you pick on first launch (see Workspace and Git flow). Pipelines are plain JSON files you can commit to Git, diff, branch, and review.
Via Git, yes - check the workspace into a repo and use standard branch/PR flows, and deploy the result to a shared server where the console has roles and an audit log. What there is not is a real-time multiplayer canvas: two people editing the same pipeline at the same moment is a merge, not a live session.
Yes. Build Pipeline (right-click a pipeline) produces a single self-contained executable that runs anywhere with nothing installed - drop it on a server or CI runner and execute it, or schedule it with cron / systemd / Task Scheduler. The embedded duckle-runner can also run a workspace pipeline JSON directly (duckle-runner --pipeline pipeline.json). See Server deployment. You can also import the engine crate (duckle-duckdb-engine) into your own Rust binary.
For 90% of common pipelines (read source -> simple transforms -> sink), yes - the Qwen 2.5 Coder model is tuned for structured-JSON generation. For long, complex pipelines you'll likely want to iterate: describe the first half, click insert, then ask for the next half. You can also swap the model: point xf.ai.llm's baseUrl at GPT-4 or Claude for more capable pipeline drafting.
No. Once llama-server and the Qwen GGUF are downloaded into your app-data directory, Duckie needs no network at all. Nor does it have to run in-process: point it at your own OpenAI-compatible endpoint and it uses that instead. Tested by killing wifi and asking it for a pipeline - works fine.
DuckDB's SQL surface is wide enough to express most ETL work, it's vectorized and fast on a laptop, it has first-class Iceberg/Delta/Parquet readers, and its extension model lets us add vector + full-text + Postgres ATTACH without code changes. Polars is great but doesn't ship the cloud/format/extension breadth we need; Spark is a great cluster but overkill for the local-first niche we're in.
See the Contributing section and crates/duckdb-engine/src/plan.rs (planner branch) + crates/duckdb-engine/src/lib.rs (executor). The shortest path: copy an existing connector with similar shape (e.g. src.rabbit for a streaming source, src.dynamodb for an HTTP+auth API), adapt, add a test, flip the palette tile.
| Symptom | Likely cause | Fix |
|---|---|---|
| Window opens but content shows "localhost refused to connect" | Release binary built without --features custom-protocol (the v0.0.7 bug) | Rebuild with cargo build --release --features custom-protocol per Build from source. The release workflow already passes this flag. |
| "DuckDB CLI not found" on Run | First-launch installer was skipped or interrupted | Open the engine setup modal from the toolbar; click Install on DuckDB |
| "Couldn't download Duckie AI Assistant (HTTP 404)" | Pinned llama.cpp build temporarily unavailable from upstream | Bump LLAMACPP_BUILD in apps/desktop/src/engine_manager.rs to a recent stable, rebuild |
| Linux: app won't launch, missing libwebkit | WebKitGTK 4.1 isn't installed | sudo apt install libwebkit2gtk-4.1-0 (Debian/Ubuntu) or your distro's equivalent |
| macOS: "App can't be opened because Apple cannot check it" | Gatekeeper, unsigned binary | Right-click the binary -> Open -> Open Anyway |
| Pipeline runs but a connector errors with "extension not loaded" | Lazy-loaded extension (e.g. spatial) downloaded mid-run and failed | Run duckdb :memory: -c "INSTALL spatial; LOAD spatial;" from a terminal to pre-install; relaunch Duckle |
| Chat panel says "AI engine not registered" | Old version of Duckle before AI shipped (pre-v0.0.10) | Update to latest release |
| Duckie generates a pipeline but Insert doesn't put anything on the canvas | Active pipeline tab has been closed; nothing to insert into | Open a pipeline (or create a new one) before clicking Insert |
| MotherDuck / Snowflake auth fails | Token expired, or PAT lacks the role you're trying to use | Regenerate in the vendor UI; paste into the Connection in Duckle |
Postgres ATTACH says "could not connect" | Local SSL mode mismatch | Connection -> Advanced -> set SSL mode to disable for localhost / require for production |
| AI tests skip with no failure | DUCKLE_DUCKDB_BIN isn't set | export DUCKLE_DUCKDB_BIN=/path/to/duckdb before cargo test |
| TLS "UnknownIssuer" / "invalid peer certificate" behind a corporate proxy | A TLS-inspecting proxy (Zscaler, Netskope, ...) re-signs traffic with its own CA | Duckle trusts your OS certificate store on top of its bundled roots, so the proxy CA in the Windows / macOS / Linux store is honoured automatically. If the CA isn't in the store, point DUCKLE_CA_CERT at a PEM file containing it. Note: DuckDB's own extension fetch (extensions.duckdb.org) and cloud reads (S3 / GCS / Azure) run inside the DuckDB engine with its own TLS, so also allow / exempt extensions.duckdb.org from inspection. |
| REST / cloud calls fail with "Connection Failed" / timeout (os error 10060) behind a proxy | The network requires an HTTP proxy to reach the internet, and Duckle is connecting directly | Set HTTPS_PROXY (and HTTP_PROXY) to your proxy URL, e.g. http://user:pass@proxy:8080, before launching Duckle - REST / cloud connectors and the updater now route through it. Use DUCKLE_HTTPS_PROXY if you want a Duckle-only proxy without changing global env. |
If you see something not listed, please open an issue with steps to reproduce + the relevant log line.
Duckle's CI pipeline runs on both GitHub and GitLab - the project mirrors to both. Push / pull-request / merge-request / tag events all trigger builds.
| Trigger | GitHub Actions | GitLab CI |
|---|---|---|
| Push to main or feature branch | .github/workflows/ci.yml | .gitlab-ci.yml (test + desktop-build stages) |
| Pull request / merge request | .github/workflows/ci.yml | .gitlab-ci.yml (same stages, rules: gate on MR events) |
Tag v* | .github/workflows/release.yml | .gitlab-ci.yml (release stage; uploads binaries to GitLab Releases) |
What each pipeline does:
npm ci + npm run build (type-check + bundle)cargo test --workspace on Linux + macOS + Windowscargo build --release --features custom-protocol then grep the binary for the embedded frontend JS chunk (catches the v0.0.7-class "binary loads devUrl" bug at PR time)See .github/workflows/ and .gitlab-ci.yml for the exact steps. The two pipelines are kept feature-equivalent so contributors can fork to either platform.
Nothing regenerates this README, the hero / flow SVGs, or the download links automatically - they are hand-maintained, so they drift unless each release updates them. Treat the README as a release artifact: walk this checklist every time before tagging.
# 0. Update the README in the SAME commit as the version bump:
# - bump every vX.Y.Z reference (the Download / Install link, badges)
# - refresh capability tables for any new sources/transforms/sinks
# - add/replace screenshots in docs/assets for shipped features
# - re-check the hero/flow SVG wording if positioning changed
# 1. Bump version in apps/desktop/tauri.conf.json
# 2. Commit (README + version together)
git commit -am "Release: bump to vX.Y.Z"
# 3. Tag + push
git tag vX.Y.Z
git push origin main vX.Y.Z
# Both GitHub Actions and GitLab CI pick up the tag and build the
# release artifacts automatically. Once green, the draft release on
# GitHub gets the binaries uploaded; un-draft + mark Latest with:
gh release edit vX.Y.Z --draft=false --latest
A server somebody can set up in a browser, an ordered plan of pipelines, a catalog of the whole workspace, and a run that stopped reading the source three times to answer one question.
/healthz means an orchestrator can tell a starting server from a wedged one, and a schedule that stops working now says so instead of failing quietly.plans.json, and can be scheduled like a single pipeline. A plan whose pipelines failed no longer reports that it worked.SELECT COUNT(*), and since nodes are views, each one re-ran the whole chain. A source to filter to sink pipeline read a 96M-row Postgres table three times to do one pass of work. Each relation is now counted once, and a sink takes its count from the Parquet footer of the file it just wrote, which is a metadata read: 0.06s against 16.7s for the equivalent count over the source. Measured on that pipeline, baseline against this release, interleaved on one machine: 56.3s to 18.8s, and 288,159,946 tuples scanned down to 96,011,803. That puts it level with a hand-written DuckDB COPY doing the same work, at 1.02x. A remote XML stream over SFTP was reading 8 KiB per round trip and now reads 256 KiB: 75 MB and 700,000 rows went from 17.0s to 10.0s.Talend jobs import straight into the canvas, and credentials are masked more carefully in exported SQL.
.item job, translates it, and opens it as a new pipeline tab, laid out on the canvas at the coordinates the job was drawn with. Measured on a real 44-job corpus: all 44 parse and 211 of 216 nodes map, the only refusal being a site-specific custom component. Nothing is written to your workspace until you save, so a job that translates badly costs a closed tab.${ENV:...} placeholders instead of guesses. Connections stored outside the job file are named, so you can fill them in or point the node at a saved connection. tMap outputs computed by Java are listed column by column with the expression to rewrite as SQL. A component with no Duckle equivalent is imported as a labelled placeholder, so the shape of the job survives rather than quietly losing a step.duckle-runner import <dir> walks the tree, converts every job it finds, and mirrors the folder layout under --out so two jobs that share a name cannot overwrite each other. Measured on a real 125-file corpus: 42 files hold a job and 83 do not (routines, contexts and SQL templates share the extension), all 42 convert with none failing, and exactly one component across the whole corpus has no equivalent - a site-specific custom one. Everything else still to resolve is credentials that were never in the job files to begin with: 119 encrypted passwords and 75 connections defined outside the job. The closing tally lists unmapped components by how often they appear, which is both the answer to "is this migration viable" and the shortest path to finishing it. --json for a script, --strict to fail a CI job.prod rewrote production_report.parquet as ${DUCKLE_PASSWORD}uction_report.parquet. The secret itself was always protected; the damage was to everything else, and it mattered most when reading the Plan or SQL view to debug. Matching is now delimiter-aware, so LOAD postgres is left intact while a one-character password is still masked in password=p'. Deliberately no minimum length: a short password is still a password.A multimodal AI data store, an importer for legacy visual ETL jobs, a chat model you choose, and two geometry transforms that finally have the second input they always needed.
src.pixeltable reads a table, optionally filtered by a Pixeltable expression, a column subset and a limit; snk.pixeltable inserts into an existing table or creates one from the incoming rows. Versioned reads work by passing myapp.media:3. The exchange runs over Parquet on both legs - Pixeltable exports, Duckle ingests with read_parquet, and on the way back Duckle writes Parquet that Table.insert takes directly - so no rows are serialised one at a time. Pixeltable is a Python library, so the desktop app provisions a private Python for it with uv on first use; nothing is installed into your own environment.ST_Union_Agg before the operation, attributes of the input layer are preserved, and features left with nothing are dropped. Thanks to @OmarMustaafa for reporting it twice with screenshots. A test now pins this contract for every component whose builder needs a second input, checked by removing a port and confirming it fails.${ENV:...} placeholders rather than guesses, connections that live outside the job file are reported rather than silently half-imported, and anything with no equivalent is imported as a labelled placeholder so the shape of the job survives instead of quietly losing a step.duckle-runner is now published as a release asset, and there are ready workflows for GitHub Actions and GitLab CI under docs/ci/. They gate every push on duckle-runner validate, which compiles pipelines to SQL without opening a source, writing a sink, or needing credentials or a network. This is the check that catches a column renamed in one commit and still referenced by another - they merge cleanly, and nothing else notices.Full notes: see the v0.6.0 release.
Power mode, context layering, and an Oracle extract that is now faster than python-oracledb with pyarrow on the same table.
Power mode (Settings -> Power mode). Two throughput settings per workspace. Pipelines at once caps how many run together; the placeholder shows the machine's core count. Spill folder points DuckDB's spill files at a bigger or faster disk. Only the lever with a measurement behind it is offered: independent pipelines scaled about 3.8x across 8 concurrent processes on a 20-core box, while splitting a single pipeline across processes measured slower (72ms to 123ms at 8-way), so there is deliberately no option for it. Each concurrent run gets its own memory limit and its own DuckDB process, so N at once needs roughly N times the memory, and the panel says so.
Scheduled runs have a ceiling. Every schedule that came due in the same tick fired at once, so ten due at midnight meant ten pipelines each sized for the whole machine. They are now bounded, by power mode where it is set and by a sane default otherwise. The headless duckle serve honours the same setting, so desktop and server agree.
Contexts can be layered (#204). A context can declare a Layer; higher layers override lower ones. A shared base plus a per-environment override is now expressible directly: give the base layer 0 and the environment a higher number, and the override applies quietly. Previously all contexts merged flat in repo order, so every intended override looked like a collision and had to be resolved by hand. Only two contexts on the same layer defining the same name are still reported, because nothing there says which should win. Workspaces that set no layers merge exactly as before.
Oracle extracts beat python-oracledb (#221). Three changes, each measured on a 1,466,723-row x 236-column table with the same query and SNAPPY on both sides:
NUMBER columns are now measured before the write instead of typed after it. Those columns have no declared width, so they used to travel as text and be typed by a pass over the finished Parquet - which is exactly the pass a direct write skips, meaning one such column forced a whole second pass over every column. The ambiguous columns are now read on their own first (about 2s for 4 of 236), their real widths pin the schema, and the file is written once. Both reads share one snapshot via a read-only transaction, so they cannot disagree.ResultSet<Row> reconstructs every value in the row; on this table that was 346 million reconstructions, measured at 11.5s of a 42.7s fetch against a 31.2s floor.NUMBER values no longer allocate a string per cell while being rescaled - 88 million allocations per run on this shape.Together: 100.7s to 65.0s in the shape reported on #221, against 68.6s for python-oracledb with pyarrow doing the same job on the same machine. With column types already pinned it is about 56.7s. Output was verified against pyarrow's: identical row counts, and equal sums, hashes, ranges and null counts across every column type. Worth noting that python-oracledb maps an unconstrained NUMBER to DOUBLE, which cannot hold the 24 significant digits one test column carries; Duckle types it exactly, so the comparison is not quite like for like and not in our favour.
A direct Parquet write no longer produces string columns (#221). With Write directly from the source enabled on a table containing any bare NUMBER, those columns were written as text while the run reported success. The source now declines the shortcut when a column cannot be typed before the write, and says so in the run log. Anyone who enabled that toggle on v0.5.9 against such a table should re-check the output.
Concurrent runs no longer fight over spill files. DuckDB's default spill location is already per-run, but setting a shared spill folder made every run share one - which reads as a flaky run rather than a bug: across three trials of four concurrent spilling queries, a shared folder lost 3 of 12 runs to a segfault or a delete failure, private folders lost 0 of 12. Each run now spills into its own subfolder.
Full notes: see the v0.5.10 release.
A complete planned-component breakdown lives in docs/roadmap.md. Highlights:
protoc at build time)orc-rust and our workspace pin)russh + russh-sftp on the ring backend, password / key auth, host-fingerprint pin)Contributions, issues, and ideas are welcome. Duckle is young and there is a lot of green field. Open an issue to discuss a change before a large PR, match the existing code style, and keep changes focused. Run cargo test and npm --prefix frontend run build before submitting. See CONTRIBUTING.md.
Thanks goes to these wonderful people who contribute to Duckle (emoji key):
mits 🚇 ⚠️ | Christian 🤔 ⚠️ 💻 | gmacc00 🚇 ⚠️ 💻 | Stéphane Heckel 🚇 ⚠️ 💻 | Steven Snowball 🚇 ⚠️ 💻 | Suffian0610 🚇 ⚠️ 💻 | add944 🚇 ⚠️ 💻 |
KNP-BI 🚇 ⚠️ 💻 | Richard Wesley 🚇 ⚠️ 💻 | micha9ski 🚇 ⚠️ 💻 |
This project follows the all-contributors specification. Contributions of any kind - code, docs, design, bug reports, ideas - are welcome and recognized here. Comment on any issue or PR with @all-contributors please add @name for code, doc and the bot opens a PR adding them.
Licensed under either of MIT or Apache-2.0 at your option.
Rust
63.6%
TypeScript
23.4%
HTML
4.7%
CSS
3.6%
Python
2.5%
JavaScript
1.6%
Open-source ETL/ELT you deploy on your own servers or cloud. Built on DuckDB: no-code/low-code visual pipelines or SQL, 385 components, dbt, CDC, data quality, reverse ETL, lineage, MCP for AI agents. No vendor cloud, no per-row billing.
1,287
stars
1,222
commits
Rust
primary language
Sep 9, 2026
updated
Duckle is an open-source ETL platform for teams who want their pipelines running on their own infrastructure. Author on a canvas, in Python or in SQL, then ship the same file to your own server or cloud account: duckle-runner serve runs it headless on a schedule, in Docker or on a box you own, with a web console, roles and an audit trail. Every pipeline is one file in git, so it outlives whoever wrote it. It compiles to SQL on DuckDB and uses every core you give the box, so a bigger instance is a faster pipeline: 96 million rows out of Postgres to Parquet in 39.9s. No vendor cloud. No per-row billing. No lock-in.
Duckle is an independent open-source project by SlothFlowLabs. It builds on the DuckDB engine but is not part of, affiliated with, or endorsed by DuckDB Labs or MotherDuck.
|
Get started |
Use the product |
Reference |
Resources |
An open-source ETL platform you run on your own infrastructure. Drag sources, transforms, validators and sinks onto a canvas, wire them together, and press Run. Duckle compiles the graph to SQL and executes it on a real columnar engine, with live previews, the generated SQL visible on every node, and no hidden state.
You build a pipeline on a laptop and deploy that same file to a server, where it runs on a schedule under a web console with roles, alerts and an audit log. Nothing is rewritten in between, and nothing is metered.
In short: a free, open-source, single-engine alternative to hosted, per-row-priced ETL platforms like Fivetran and Airbyte - one pipeline for ingest, transform, and load that runs anywhere, and can also run dbt on DuckDB inside the same tool.
Three things set it apart:
| Visual, never opaque | The canvas compiles to SQL you can read, and every node has a live preview tab. No black box. |
| An assistant with no API key | Runs in-process by default, or against your own OpenAI-compatible endpoint. Your prompts and your data stay inside your infrastructure either way. |
| Single-file binary, no bundled DB | 73 to 110 MB depending on platform (it embeds the headless runner + MCP server). DuckDB downloads on first launch with a guided step. AI engine is opt-in. |
| Native speed | Execution runs through DuckDB: vectorized, columnar, local. A clean-and-export job that crawls in a spreadsheet finishes in milliseconds. |
| Git-friendly by design | Pipelines, connections, contexts, and routines persist as plain files in a folder you pick. Diff them, branch them, review them. |
| 360+ components ready today | Files, databases, warehouses, lakehouses, object stores, SaaS APIs, NoSQL, streaming brokers, vector DBs, FTP, IMAP, SMTP. Each is covered by tests. |
| Honest about scope | Single-machine and embedded by design. Built to make local and small-team data work fast, not to replace a distributed warehouse. |
| 60 UI languages | Topbar, palette, chat assistant, properties panel, and common dialogs ship localized. English, Spanish, Chinese (Simplified + Traditional), Hindi, Arabic, Portuguese (Brazil), Bengali, Russian, Japanese, Punjabi, German, Korean, French, Vietnamese, Telugu, Marathi, Turkish, Tamil, Urdu, Persian, Polish, Italian, Ukrainian, Indonesian, Thai, Dutch, Hebrew, Swedish, Greek, Czech, Hungarian, Romanian, Filipino, Malay, Norwegian, Danish, Finnish, Catalan, Bulgarian, Slovak, Croatian, Serbian, Slovenian, Lithuanian, Latvian, Estonian, Khmer, Burmese, Sinhala, Nepali, Swahili, Afrikaans, Welsh, Irish, Icelandic, Albanian, Azerbaijani, Mongolian, Kazakh. RTL (Arabic, Hebrew, Persian, Urdu) supported. Switch languages from the topbar globe. |
| Open source | Dual-licensed MIT OR Apache-2.0. Yours to use, fork, and extend. |
Real pipelines, built and run in Duckle - not mockups.
A 5M-row pipeline: a CSV, a Parquet file, a DuckDB table, and a SQLite table enriched through one visual Map (3-way join), no SQL.
Left: the visual Map editor - main plus lookups, per-output expressions, an inline filter. Right: Parallelize fanning out aggregate, window, and top-N branches.
One run, many branches: 16 nodes finish in a few seconds. Concurrency auto-detects from CPU cores; branches write to Parquet, CSV, DuckDB, and SQLite at once.
Left: DuckLake CDC change-feed mirrored via upsert + delete propagation (100k rows). Right: watermark incremental load over 5M rows, advancing state only on a fully successful run.
samples/orders.csv, hit Autodetect schema. Drag a Filter, wire it up. Drag a Parquet sink with an output path. Press Run, watch the nodes light up.That's a real, native ETL pipeline built and run in under a minute. CSV is just the easiest first node; swap in Parquet, JSON, S3, Snowflake, MongoDB, or Stripe the same way.
Pick the binary for your OS from the latest release:
| OS | Asset | How to run |
|---|---|---|
| Windows | Duckle-windows-x64.exe | Double-click. Unsigned binary - Windows SmartScreen will warn the first time; click "More info" -> "Run anyway". |
| macOS (Apple Silicon) | Duckle-macos-arm64 | chmod +x Duckle-macos-arm64 && ./Duckle-macos-arm64. Right-click -> Open the first time to bypass Gatekeeper. |
| Linux (x86_64) | Duckle-linux-x64 | chmod +x Duckle-linux-x64 && ./Duckle-linux-x64. Requires WebKitGTK 4.1 (libwebkit2gtk-4.1-0 on Debian / Ubuntu). |
The single-file binary above is all you need for Build Pipeline too: the headless runner is embedded into the app at build time, and exporting a pipeline produces ONE self-contained executable (the engine, the DuckDB CLI, any needed extensions, and the resolved pipeline are all inside that one file). Copy that single file to your server and run or schedule it - no separate runner download required.
One command, nothing installed: it scaffolds sample data and a pipeline, compiles it to SQL, runs it on DuckDB, and shows you the rows.
uvx duckle quickstart
Paste this into Claude Code, Cursor, or Codex:
Run
uvx duckle quickstartto build my first pipeline and run it
Nothing to install first. The agent fetches Duckle and the DuckDB engine on demand, runs a real pipeline, and shows you the rows.
If you do not want the desktop studio, install just the headless runner. It is about 27 MB rather than 100 MB or more, has no GUI dependency, and is what a build step actually needs.
pip install duckle
That is the whole install. It brings the DuckDB CLI with it (via the duckdb-cli package published by the DuckDB Foundation), so there is nothing else to fetch and it works offline. Wheels ship for Linux, macOS and Windows on x86-64 and arm64.
It also gives you a Python API, where pipelines are built as code and executed by DuckDB rather than by Python:
import duckle
from duckle import col
(duckle.read_csv("orders.csv")
.where(col.amount >= 20)
.derive(total="round(amount * 1.2, 2)")
.write_parquet("out.parquet")
.run())
Python expressions compile to vectorized SQL at plan time, so no rows pass through the interpreter. See the PyPI page for the full API.
The same package provides the duckle command-line runner for CI, cron, and containers - it bundles the headless runner and the MCP server per platform:
pip install duckle # or run ad hoc, no install: uvx duckle --help
Pipelines execute as SQL on the DuckDB CLI, so the runner needs a duckdb on PATH or DUCKLE_DUCKDB_BIN set (pip install duckdb-cli is the quickest route). Validation does not:
duckle validate # compile-check every pipeline under ./pipelines
duckle validate --json # machine-readable, for a CI step
duckle --pipeline my.json # run one
validate opens no source and writes no sink, so it needs no engine, no credentials and no network. Exit codes are stable: 0 clean, 1 a real finding (a pipeline failed or did not compile), 2 the runner could not start (bad usage, unreadable file, missing engine).
The binary is 73 to 110 MB depending on platform (it embeds the headless runner and the bundled MCP server). On first launch you'll be guided through downloading two engines into your app-data directory:
| Engine | Size | Required? | What it powers |
|---|---|---|---|
| DuckDB CLI | ~30 MB + extensions | Yes - cannot run pipelines without it | Every source / transform / sink that runs as SQL |
| Duckie AI Assistant | ~1.1 GB (llama-server + Qwen 2.5 Coder 1.5B GGUF) | Optional | The chat sidebar that generates pipelines from natural language |
App-data location:
%APPDATA%\io.duckle.app\engines\~/Library/Application Support/io.duckle.app/engines/~/.config/io.duckle.app/engines/Delete the engines/ folder if you ever want to force a fresh install.
A worked example using the bundled samples/orders.csv data.
samples/orders.csvmain output port to the Filter's main input.status = 'paid' (you can write raw SQL or use the visual builder)pass (rows matching) and reject (rows that don't).pass port to the Parquet sink.paid_orders.parquet. Write mode: overwrite. Compression: zstd.You build a pipeline on your laptop. The server runs that same file. Nothing is rewritten, exported or converted in between.
flowchart LR
D["Duckle Desktop<br/>your machine"] -->|deploy, needs admin| W
B["Console in a browser<br/>your machine"] -->|turn it on, needs operator| W
W["Workspace on your server<br/>a new schedule lands OFF"] --> C["Scheduler<br/>every 15s, takes what is due"]
C --> R["It runs<br/>on that box, unattended"]
R --> O["Run history, logs, metrics,<br/>alerts, and an audit log"]
O -->|you watch it here| B
| How | What you get | |
|---|---|---|
| Server | duckle-runner serve --workspace /srv/pipelines | Headless web console, cron scheduler, roles, audit log, alerts |
| Docker | Dockerfile.web | The same console in a container, behind your own ingress |
| CI | duckle-runner --pipeline p.json | Any runner. Exit codes and NDJSON logs, nothing to install |
| Standalone | Build Pipeline | One self-contained executable. Drop it on a box, run it from cron or systemd |
| Desktop | The app | Author, debug and inspect. Optional, and never required to run anything |
Nothing here depends on a person's machine being switched on:
Working recipes for AWS (EC2, ECS, EKS), Azure (VM, Container Apps, AKS) and Google Cloud (Compute Engine, GKE), with manifests and the mistakes worth avoiding, are at duckle.org/deploy. Three things worth knowing before you start:
--token, set DUCKLE_CONSOLE_TOKEN, or create accounts with duckle-runner console add-user before exposing it. An empty value is refused rather than treated as absent, so an unresolved secret fails loudly instead of opening that window. Who can do what, and how one request is decided, is set out under Sign-in and roles.serve, not in the editor. Start the editor with schedules armed and it now says so rather than leaving you to wonder why nothing fired.GET /healthz needs no credential and answers ok, so a Kubernetes probe or a load balancer can check liveness without holding a token. Every other route is authenticated, so pointing a probe anywhere else reports the pod unhealthy forever.POST /api/deploy lands a pipeline on a server from wherever it was authored, with the schedule it should eventually run on:
curl -X POST https://duckle.internal/api/deploy \
-H "Authorization: Bearer $DUCKLE_TOKEN" \
-d '{"name":"orders-load",
"pipeline": '"$(cat orders-load.json)"',
"schedule":{"intervalMinutes":30}}'
Two things are deliberate. The schedule arrives disabled, so a cadence someone set while testing on a laptop cannot start firing the moment it reaches production; enabling it is a separate call. And deploying needs admin while enabling needs operator, because a deployed pipeline runs shell and SQL on that host: shipping the code and starting it are two acts, and the audit log records both with the name of whoever did them.
A person signs in and gets a session. A machine has no browser and nobody to rotate a password, so it gets a key of its own:
duckle-runner console key-add ci-deployer --role admin --expires-days 90
duckle-runner console key-list # role, state, and when each was last used
duckle-runner console key-revoke ci-deployer
A key carries its own role, so a deploy runner can be admin while a metrics scraper is viewer. It is printed once and stored only as a hash, so a lost key is replaced rather than recovered. key-list shows when each was last used, which is the question actually worth answering before revoking one, and revoking takes effect immediately on a console that is already running rather than at the next restart. Revoked keys are marked rather than deleted, so a key that turns up in an old log can still be named.
Accounts, sessions and keys live in .duckle/console.db. An existing console-users.json is carried into it on first start and renamed to .migrated, so an upgrade neither locks anyone out nor destroys the only copy of a credential store. For the roles, and a diagram of how one request is decided, see Sign-in and roles.
duckle-runner serve is an ordinary service. Run it on EC2, EKS, a VM or a container next to everything else you operate, and scale it the way you scale any service:
DUCKLE_THREADS when you would rather it did not take the whole machine.memoryLimitMb per stage, or a workspace default, and spill to disk past it.DUCKLE_MAX_CONCURRENT_RUNS raises how many run together; it ships at 1 so an unattended server stays predictable until you decide otherwise.duckle-runner work drains a queued batch from as many workers as you start, on as many hosts as you like, each claiming its items under a lock so nothing runs twice.Measured, rather than asserted:
The one thing Duckle does not do is split a single query across a cluster the way a distributed warehouse does. When you need that, push the work down into the system that has it and let Duckle orchestrate around it.
Want the studio to publish straight to a running server instead? That is the other route: connect a server once, then Deploy to a server from the editor. Step by step in docs/current/server-deployment.md.
Promoting from CI instead, or driving Duckle from Airflow, Dagster or Temporal? See docs/current/ci-and-orchestration.md, with copyable GitHub Actions and GitLab CI templates in docs/ci/.
Want to know exactly what crosses the wire, and where every credential is stored? docs/current/client-server-architecture.md is the diagrammed answer, sharp edges included.
The in-app scheduler runs only while Duckle is open. To run a pipeline on a server with no desktop app, Build Pipeline turns it into ONE self-contained executable - the equivalent of a standalone "Job".
Right-click a pipeline (in the project tree or on the canvas) and choose Build Pipeline. The output is a single file named after the pipeline (orders_etl.exe on Windows, orders_etl on macOS / Linux) that embeds everything it needs:
On first run it self-extracts to a temp cache and uses its own embedded DuckDB, so the server needs nothing installed - no Duckle, no DuckDB. There is no folder to copy, no run.sh, and no separate runner download. A CSV-to-CSV pipeline builds to about 28 MB; only the extensions a pipeline uses are bundled, so the file stays lean.
./orders_etl # or orders_etl.exe on Windows
The process exits 0 on success and non-zero on failure, and writes the same NDJSON run logs under logs/ (Splunk / Dynatrace friendly).
Build options
| Option | What it does |
|---|---|
| Target OS | Pick Windows, Linux, or macOS in the build dialog. The native OS always builds; a Linux server file can be cross-built from any host (the Linux engine is bundled for you), while a macOS file can only be produced on a Mac. Appending the payload makes the file unsigned, so do not codesign / Authenticode-sign it. |
| Context | Pick a context at build time; its non-secret variables are baked into the pipeline. |
| Secrets: Environment | Each secret becomes a ${ENV:KEY} placeholder, so nothing sensitive is written into the file. The runner resolves real environment variables first, then a secrets.env (KEY=VALUE lines) placed next to the file. |
| Secrets: Passphrase | Secrets are encrypted inside the file with AES-256-GCM, decrypted at run time from the DUCKLE_BUNDLE_PASSPHRASE environment variable. |
Schedule it with whatever the server already has - point the OS scheduler straight at the file:
# Linux cron - run every day at 02:00
0 2 * * * /opt/duckle/orders_etl >> /var/log/orders_etl.log 2>&1
On Windows use Task Scheduler; on macOS a launchd plist; on Linux a systemd timer. Full examples in docs/current/scheduler.md.
Run against an existing workspace - the same embedded headless runner can also execute a pipeline JSON directly, resolving context the way the app does:
duckle-runner --pipeline /path/to/pipeline.json [--workspace /path/to/workspace] [--duckdb /path/to/duckdb]
follow)A scheduled pipeline already consumes a stream without gaps: a source that
tracks its position (src.kafka with trackOffset, xf.incremental) resumes
where the last successful run stopped. What a schedule cannot give is
latency - the scheduler wakes every 15 seconds, and every run pays process
start, DuckDB resolution and document parsing again.
follow keeps the same execution model and removes that per-batch overhead.
The document is read and resolved once, the engine is built once, and the
pipeline then runs in a loop. Each pass is one micro-batch:
duckle-runner follow /path/to/pipeline.json --idle-ms 500
| Flag | Meaning |
|---|---|
--idle-ms N | wait N ms after a pass whose sinks wrote nothing (default 1000) |
--max-batches N | stop after N passes (default: until stopped) |
--on-error stop|continue | stop on a failed batch (default), or keep going |
A failed batch never advances the source position. The position is queued
during the run and written only when the run reaches ok, which is after every
sink has written - so a failure anywhere, transform, quality gate or sink,
leaves the position where it was and the next pass re-reads exactly the records
that did not land. Killing the process is safe for the same reason; Ctrl-C
finishes the batch in hand first, which only saves you a truncated output file.
That ordering is the difference between a correct micro-batch loop and a lossy one, so it is covered by a regression test that fails if the position ever advances past a batch that did not land.
backfill)Production deployments are headless, so replaying from an earlier point should not mean getting at the server's workspace through a GUI:
duckle-runner backfill list --pipeline ./pipelines/daily.json
duckle-runner backfill set --pipeline ./pipelines/daily.json --node inc --value 2026-01-01 --type TIMESTAMP
duckle-runner backfill clear --pipeline ./pipelines/daily.json --node inc
duckle-runner backfill list --pipeline ./pipelines/daily.json --json # for CI and agents
Five node kinds keep state in that folder, and only two resume from a value a
person can write down. xf.incremental (a watermark) and
src.ducklake.changes (a snapshot id) can be set; a src.kafka resume offset,
a src.spool byte position and an xf.tumble buffer pointer are listed and can
be cleared, but set on them is refused. Writing {value,type} over a
tumbling window's state would drop the pointer to the rows it is holding and
delete them on the next run, with nothing to report it.
Clearing is not always a full reload, and the tool says so: a Kafka node with
startFrom: latest skips whatever is already in the topic when it has no saved
offset, so clearing it moves PAST that backlog rather than replaying it.
The same three operations are on the console API and MCP, so a replay can be driven from CI or an agent as well as the CLI:
GET /api/watermarks?file=pipelines/daily.json viewer
POST /api/watermarks?file=pipelines/daily.json operator
DELETE /api/watermarks?file=pipelines/daily.json&node=ID operator
Reading needs a viewer; changing what the next run processes needs an operator. All four surfaces - desktop panel, CLI, API, MCP - call the same engine functions, so the kind guard cannot be bypassed by picking a different one.
listen + src.spool)src.webhook and src.websocket collect INSIDE a pipeline run: they bind or
connect, take N messages or time out, and stop. Right for a one-shot capture,
wrong for anything continuous - between runs the port is closed and arriving
requests are refused. Under follow that gap is every batch boundary.
listen is the other half. It keeps the listener up and appends what arrives
to an append-only NDJSON spool; a pipeline reads that spool with src.spool,
from wherever the last successful run stopped:
duckle-runner listen --port 9000 --spool ./spool/hooks.ndjson --path-filter /hooks
duckle-runner follow ./pipelines/hooks.json --idle-ms 500
Arrival is decoupled from processing, so a slow batch, a failed batch or a restart costs nothing that already arrived. Append-only plus a byte offset is the whole trick: the reader never deletes and the writer never rewrites, so there is no race between them.
A record is {received_at, method, path, headers, json|body} - a JSON body is
embedded under json so the pipeline can address its fields, and anything else
is kept verbatim under body rather than dropped for not parsing. The spool is
written and flushed BEFORE the 200 goes out, because a 200 tells the sender its
delivery is safe and webhook senders do not retry those.
--memory-limit, --threads, --max-temp-size)Duckle targets one machine and will use it. On a dedicated box that is what you want; on a shared server one unexpectedly large job should not be able to take everything else down with it.
duckle-runner --pipeline ./daily.json --memory-limit 24GB --threads 8 --temp-dir /data/duckle-tmp --max-temp-size 300GB
--max-temp-size is the one worth setting deliberately. DuckDB's own
default is 90% of available disk space, so without it a single large join or
sort can fill the volume the OS is on - which is an outage, not a slow
pipeline. --memory-limit is a spill threshold rather than a hard ceiling:
above it DuckDB writes to the temp directory and keeps going, so the limit
buys predictability, not failure.
Each run spills into its own subdirectory of --temp-dir. Pointing several
concurrent runs at one shared directory is what a person does to move spill
onto a bigger disk, and it used to make them unsafe: four concurrent spilling
queries sharing a directory lost 3 of 12 to segfaults and delete failures.
The flags set the same variables the engine reads (DUCKLE_MEMORY_LIMIT,
DUCKLE_THREADS, DUCKLE_TEMP_DIR, DUCKLE_MAX_TEMP_DIR_SIZE), so a flag, a
workspace-wide export and a per-stage setting all land in one place, with the
most specific winning.
src.changed)A pipeline that watches a bulk source should not pay for the object to find
out whether it was needed. src.changed compares what a HEAD or an SFTP stat
reports against the last fingerprint it successfully processed, and emits
a row only for what moved. https://, s3:// (including MinIO, Backblaze B2,
Cloudflare R2 and other S3-compatible stores, through a saved connection or
credentials on the node) and sftp://.
Two shapes, because they are the same question asked of a different number of objects:
sftp:// directory or an s3:// prefix of immutable
files. Lists it, compares each entry, and emits the new and changed ones as
ordinary rows for a ctl.foreach or an artifact copy downstream. S3 listings
follow continuation tokens, so a prefix larger than one page is enumerated
fully rather than silently truncated at the first thousand.Rows carry uri, name, size, modified_at, etag, fingerprint and
status (new / changed).
A quiet poll is not a plain success. When nothing changed the node reports
unchanged, so a working poll and a broken one are told apart - a healthy
source can be unchanged hundreds of times between updates, and that has to
stay countable.
Fingerprints are conservative on purpose. None of the signals are guarantees: an ETag can be absent, can weaken under compression, and on S3 is a digest-of-digests for a multipart upload rather than the object's hash; Last-Modified has one-second resolution; SFTP offers mtime and size. A missing or unreadable signal therefore counts as changed. Re-reading something unnecessarily costs compute; skipping something that did change loses data and reports nothing.
What was processed advances only when the whole run succeeds, and only for
rows that were actually emitted - so a failure downstream re-offers the same
files, and a run capped by maxEntries does not mark the remainder as done.
src.ducklake.maintain)A lakehouse that is written to continuously eventually needs maintaining as well as filling: frequent incremental writes leave many small files, snapshots accumulate, and files stay referenced longer than they need to be. Those operations used to live outside Duckle.
Each operation is one DuckLake function, and its options are that function's options - compact, rewrite files heavy with deletes, expire snapshots, clean up files an expired snapshot released, delete orphaned files, flush inlined data, or read per-table storage statistics. Nothing here invents storage semantics, so what it does follows the installed DuckLake rather than anything Duckle decided.
The result comes back as ordinary rows, which is what lets a quality check
or an alert read a compaction the way it reads anything else, and the node
reports what changed: ducklake compact: 1 row(s) - files 4 -> 1, 1.1 KB -> 513 B.
Three things about deleting, since that is where this gets dangerous:
Two maintenance runs against one catalog serialise on a lock rather than racing, so a weekly compaction overlapping a monthly cleanup waits instead of failing a two-hour job at its commit.
xf.artifact.copy)An artifact is a reference - a uri, a media type, a size, a hash - so a pipeline can carry one around for nothing. At some point the actual bytes have to move, and that is this step: between "the feed says there is a new 4GB bundle" and "it is in our raw zone, hashed, and we can prove which bytes we parsed".
It reads a uri column - whatever src.changed, src.artifact or a query
produced - and copies from https://, s3://, sftp:// or a local path to
an s3:// prefix or a local directory.
Streamed and hashed in one pass. Memory is bounded by the part size rather
than by the object, so a 40GB model file does not become 40GB of RSS, and the
sha256 recorded is of the bytes that actually transferred. Reading twice -
once to hash, once to upload - would double the transfer off a remote source;
hashing first would mean holding the whole thing.
Naming is keep (the source's file name), path (its layout preserved under
the prefix) or hash (content-addressed, which makes the store immutable and
de-duplicating at the cost of reading each source twice, because the key is
the hash). A source-derived name can never climb out of the destination
prefix.
ifExists: skip is the default and is what a raw zone wants: re-running a
feed does not re-upload what already landed. The row still comes out, with
copied = false, because downstream still needs to know the artifact exists.
Emits uri, source_uri, name, media_type, size_bytes, sha256 and
copied.
Remote artifacts reach the signed run manifest. .ducklock pinned local
file inputs by path, and a remote object has no path - so the boundary that
matters most in a raw-zone pipeline, where the bytes came from, was the one
thing the manifest did not record. Every object a run reads or writes now
appears in it with its uri, size, media type, and either a sha256 when the
bytes actually passed through the run or an ETag and mtime when they did not.
An object that was merely observed carries no hash, because claiming one would
be a lie. The manifest also records the resource limits the run was given, so
two runs that spilled differently can be told apart from two runs handed
different budgets.
xf.tumble)Aggregating a stream by time needs a window to stay open across batches, and
needs to know when it can be closed. xf.tumble assigns each row to a
fixed-size bucket by its EVENT time, holds it until the bucket closes, then
emits it with window_start / window_end for an ordinary GROUP BY
downstream.
Closing is decided by a watermark - the greatest event time seen so far, across runs - not by the wall clock. Replaying last year's data therefore produces last year's windows, instead of finding every one of them older than "now" and closing the lot at once.
allowedLateness holds a window open past its end for out-of-order arrivals.
Anything that arrives after its window was already delivered is dropped and
counted, not emitted: sending it would hand a downstream consumer a second,
partial copy of a window it already has, with different numbers in it.
The rows in still-open windows and the watermark ride the same deferred flush as every source position, so a batch that fails downstream leaves them intact and re-processes rather than losing what it was holding.
To run and monitor pipelines on a server with a browser instead of the desktop app, start the built-in web panel - it is part of the same duckle-runner binary, so there is nothing extra to install:
duckle-runner serve --port 8080 --workspace /path/to/workspace
Open http://localhost:8080. The panel has eight views:
Runs execute in-process through the same engine, are written to the same run history (<workspace>/runs/) and logs (<workspace>/logs/), and a built-in scheduler triggers any pipeline whose schedule has elapsed - so the server itself runs your schedules, no OS cron needed.
A schedule runs one pipeline. A plan runs several, in steps: everything inside a step goes at once, and the next step waits for it. A step that fails stops the ones after it, so nothing runs against data that was never produced.
That is the shape most nightly loads already have. Without it they get written as three schedules set a few minutes apart and hoped over, which works until the extract takes four minutes instead of two.
Build one wherever you are: the Plans tab in the web console, or the Plans tile under Operate in the desktop app. Add a step, put pipelines in it, and the card draws the chain it will run.
EXTRACT PUBLISH
orders.json --> export.json
customers.json
Two things worth knowing:
serve on your server or by the desktop app on a shared workspace - both read plans.json and schedules.json, and both decide it the same way.Plans live in <workspace>/plans.json, so they are a file in git alongside the pipelines they order.
Start from where you actually are.
Running it on your own machine? Nothing to do. On 127.0.0.1 with no accounts the console is open, because anyone who can reach it is already sitting at the machine, and asking them for a password would protect against an attacker who has already won.
Put it on a server and it refused to start? That is the feature. The console can run any pipeline in the workspace, and a pipeline can run shell and SQL, so reaching it is the same as running code on that host. A bind it cannot authenticate fails rather than serving anyone and printing a warning nobody reads. The shortest way past it:
DUCKLE_CONSOLE_TOKEN=<secret> duckle-runner serve --host 0.0.0.0 --port 8080
More than one person? Give each of them their own, so the audit log can name them. The token is printed once and kept only as an Argon2id hash:
duckle-runner console add-user reporting --role viewer
duckle-runner console add-user ops --role operator
duckle-runner console list
A machine needs in? CI, a scraper, or your own laptop deploying: those have no browser and nobody to rotate a password, so they get a key instead of an account. See API keys.
| Role | Can |
|---|---|
viewer | Read the dashboard, run history, logs, schedules and catalog. |
operator | Everything a viewer can, plus run pipelines and change schedules. |
admin | Everything an operator can, plus deploy pipelines, connections, credentials, the audit log and the workspace itself. |
The split follows what an action can destroy, not which screen it lives on. It is why deploying a pipeline needs admin while turning its schedule on needs operator: shipping code to a host and deciding when trusted code runs are different sizes of decision.
Three ways to prove who you are, one identity, one check, and every outcome recorded:
flowchart LR
R([Request]) --> C{"Session cookie?"}
C -->|within 12h| ID["Identity<br/>name + role"]
C -->|no| B{"Bearer token?"}
B -->|API key| ID
B -->|account token| ID
B -->|nothing| U["401<br/>sign in"]
ID --> P{"Role enough<br/>for this route?"}
P -->|yes| OK["It happens"]
P -->|no| F["403<br/>refused"]
OK --> A[("audit log<br/>who, what, when")]
F --> A
U --> A
Two things follow from that shape. A refusal is recorded as carefully as a success, so audit --outcome denied answers "who is reaching for what they do not have". And a route with no entry in the permission table needs admin, so a route added later is locked down rather than accidentally left open.
A browser trades its credential for a session cookie, so it never stores the credential itself: the cookie carries a random session id, is HttpOnly and SameSite=Strict, is marked Secure when a proxy tells Duckle the browser is on https, and lasts 12 hours. Sessions survive a restart, so a rolling deploy does not sign your team out.
Accounts, sessions and keys live in <workspace>/.duckle/console.db. Nothing in it can be replayed: an account token is an Argon2id hash, and a session id and an API key are both generated with 256 bits of entropy and stored as SHA-256, so a copy of the file or a backup of the workspace admits nobody. An older console-users.json is carried in on first start and renamed .migrated, so upgrading neither locks anyone out nor destroys the only copy of a credential store. The same accounts, roles and keys cover duckle-runner web.
Read it back from the Audit view, or from a terminal with no server running:
duckle-runner audit # newest first, 50 by default
duckle-runner audit --outcome denied # who reached for what they do not have
duckle-runner audit --actor ops --action schedule # one person, one family of actions
duckle-runner audit --limit 500 --json # for a collector
allowed means the caller was permitted to proceed, not that the work then succeeded - run history answers that. Reads are not recorded, so a dashboard polling every few seconds does not bury the entries worth seeing. A page says when older entries exist beyond it, and a line that will not parse is counted rather than silently skipped.
Still put it behind a reverse proxy if you need TLS.
The editor imports one job at a time, which is how you try Duckle. This is how you leave another tool: point it at a checkout and convert everything.
duckle-runner import ./legacy-jobs # convert the tree into ./imported
duckle-runner import ./legacy-jobs --out ./pipelines # somewhere else
duckle-runner import ./legacy-jobs --json # for a migration script
duckle-runner import ./legacy-jobs --strict # CI gate, exits 1 if anything needs a person
The folder layout is mirrored rather than flattened, because two jobs in different folders routinely share a name and flattening would silently drop one. Files that are not jobs - routines, contexts, SQL templates - are reported separately rather than counted as conversions, and no empty pipeline is written for them.
Reusable job bodies convert alongside jobs, and a job's children resolve to the files they became, so the master/child/joblet graph survives the move rather than arriving as a set of disconnected pipelines. A loop or iterate body is lifted into its own pipeline that the parent calls, which is why the file count comes out higher than the job count.
The closing tally is the number to decide on. It says how many jobs came across clean, how many need a person, and which components have no equivalent yet, sorted by how often they appear. On a real 125-file corpus that list had a single entry, a site-specific custom component: coverage is the head of the distribution, so a corpus usually converts far better than a raw component count suggests.
What remains is credentials and Java. Credentials were never in the job files: encrypted
passwords become ${ENV:...} placeholders and connections defined outside the job are
named so you can point them at a saved connection. Java is the part that needs a person,
and the report separates it so you can see how much there is:
A component with no equivalent is imported as a named placeholder and reported. That includes a job body's input and output ports: a child pipeline runs for its side effects, so it does not yet take rows from its caller or hand them back.
A SQL step that changes the database is reported, not converted. A SQL step returns
rows and compiles into a view, so a step carrying an UPDATE, MERGE or CREATE cannot
become one: it would reach the database wrapped in CREATE VIEW and fail there. That is
knowable at import, so it is said at import. On a 125-file corpus, 16 steps.
How a write writes is carried across. A warehouse sink records whether it appends rows or amends the ones already there, and importing that as the default write mode turned an append into a full-table replace - so on a table several nodes write to, each one erased the one before it. The write action now comes across, with the key it matches on taken from the columns the schema marks as keys. An action with no exact equivalent here is reported rather than widened in silence.
The order subjobs run in is kept. Most subjobs are not linked to each other at all; they run one after another in the order the file lists them at the end, and that order was being dropped. A job that wrote a table in one subjob and read it in the next then arrived as two things that could happen in either order. Branches of a parallel fork are the one part that genuinely does not run in declared order, so they are left out of the chain.
Intermediate work moves to DuckDB. A job written against a warehouse uses it as
working storage as well as a destination: it writes a staging table, reads it back, joins
it, writes it again, and every one of those hops is billed for rows that were produced on
this machine in the first place. So a table the imported project both writes and reads is
mirrored into <workspace>/.duckle/staging.duckdb as it is written, and the reads are
pointed at the mirror. The warehouse write is left exactly as it was, which is what makes
this safe to do unasked: every table still lands where it landed before, so nothing
downstream of the project can tell the difference. Only the reads move.
A read moves only when the whole of it can. A query that also names a table the project does not write still needs the warehouse to resolve it, so that read stays - and so does the staging table it reads, since a mirror would then be serving only half of what the project asks for. Neither does a read the job could run before its own write: within one pipeline the write has to lead to the read, by rows or by an ordering link, because a warehouse table nothing wrote yet holds stale rows while a local one is simply not there. A mapper's second input is judged by where its mapper sits, since that is when a lookup is loaded and nothing feeds the lookup itself; where such a read does move it is held until the mirror has been filled. A mapper that reads a table and produces the write back to it keeps reading the warehouse, because what the lookup feeds is what changes the table. Anything else, including a query assembled at run time, is mapped as it was.
Every other lineage view in Duckle answers about one pipeline. The catalog answers about the whole workspace, by joining pipelines through the assets they name: two pipelines that read and write the same table are connected whether or not anyone drew a line between them.
duckle-runner catalog lint # CI gate, exits 1 on findings
duckle-runner catalog diff main # what this branch does to the graph
duckle-runner catalog build # scan every pipeline
duckle-runner catalog assets # every table, file, topic and endpoint
duckle-runner catalog impact postgres://db:5432/sales.public.orders
duckle-runner catalog orphans # written here, read by nobody
duckle-runner catalog owners # what nobody has claimed
impact is the blast radius: the pipelines that read an asset, the assets they write, everything downstream of those, and how many hops away each is. Assets that could not be named are counted on every answer rather than dropped, so a partial graph never looks complete. An asset name nothing in the workspace uses exits non-zero, including under --json, so a mistyped name in a CI gate fails instead of reporting an empty blast radius.
build walks the whole workspace, skipping Duckle's own folders (runs, logs, connections, .duckle), so pipelines kept in subfolders are included. The saved graph records what it was built from, so it knows when the pipelines have moved on: the CLI rebuilds on read rather than answering from a stale graph, and the console says "pipelines have changed since this was built" instead of quietly presenting an old blast radius as current. The console reports it rather than rebuilding, because reading the catalog is a viewer action and rebuilding writes a file - Rescan is the operator's button. The check is stat only, so it costs nothing to make on every read; a same-length edit inside the same millisecond would slip through, which is the price of not hashing every pipeline on every read. Asset names never carry a credential: a mongodb://user:pass@host uri or an ODBC connection string is reduced to the address before it becomes a name, which also keeps the name stable when the password is rotated.
Add <workspace>/owners.json and it also tells you who to notify. Rules are globs and the first match wins, so a narrow rule above a broad one carves out an exception:
{
"assets": [
{ "match": "/lake/raw/pii_*", "owner": "Privacy Office", "contact": "privacy@example.com",
"description": "Landing zone for regulated source tables.", "tags": ["raw", "pii"] },
{ "match": "/lake/raw/*", "owner": "Data Platform", "contact": "data@example.com" }
],
"pipelines": [{ "match": "*-ingest-*", "owner": "Ingest Squad" }],
"terms": { "active customer": "Ordered in the last 90 days." }
}
The same file carries the human half of the catalog: an optional description
and tags per rule, and a workspace terms glossary for the words three teams
would otherwise each define differently. Every one of those is optional, so an
owners.json written before they existed still loads unchanged. They live here
rather than in a file of their own because they are authored, reviewed and
committed alongside ownership, and a second file would drift from this one.
Every run records which assets it read and wrote, with row counts, under the same names the graph uses - so the catalog can answer the first question anyone actually asks of an entry: is this current? A table nobody has written for three weeks is the interesting one, and no amount of structure reveals that. Only successful runs count towards freshness: a failed run may have written nothing, or half of something, and showing either as the last write would make a broken load look like a fresh table.
Assets also carry the columns the pipelines declare, unioned across every node that touches them - a pipeline reading three columns of a table another writes twenty to does not make the table three columns wide. They come from the schema a node already carries, so building the graph still opens no source and needs no credentials. No declared columns means none are known, which the catalog does not confuse with the asset having none.
In the desktop app this is the Data Catalog screen (Home -> Govern): search every asset by name, owner, tag or column; see who writes it, who reads it, what columns are declared and when it was last written; and set the owner, description and tags without leaving the app. Saving writes a rule for that exact name above any wildcard covering it, so describing one file never re-describes its neighbours. Read live schema opens the source on demand through a node that already reads it, so it authenticates the way the pipeline does - it is never done just because a screen was opened.
catalog lint is the gate for a CI job: it exits 1 when it finds something.
It reports ownership rules that match nothing - almost always a typo or a
renamed asset, and a failure that is otherwise silent, because the team the
rule names simply never gets told about anything - patterns that will not
compile (they own nothing, safely and invisibly), and nodes the graph could not
name. Unowned assets are reported but only fail under --strict: most
workspaces have a long tail nobody will ever claim, and failing CI over it on
day one is how a useful check gets deleted from the pipeline instead of acted
on.
catalog diff <rev> answers what a change does to the graph, which is the
question a review of a data platform actually asks: which assets appear, which
disappear, and - the one that matters most - which are still there but have
lost every pipeline that wrote them. A deleted asset is loud, because
something errors. An asset nothing writes any more is silent: no error, no
missing file, the table simply stops moving and whoever reads it finds out
weeks later. The revision is read straight from git's object store, so nothing
is checked out and it is safe to run on a dirty worktree.
The console's Catalog view now shows the same facts as the desktop screen -
description, tags, columns and freshness - because both are assembled by one
function in the engine rather than two that would drift. The same answers are
available over MCP as workspace_impact.
snk.email and snk.rest are pipeline nodes - they need wiring into every pipeline and cannot fire when a pipeline dies before reaching them. <workspace>/alerts.json watches the runs themselves, for both the desktop scheduler and the server:
{
"rules": [
{ "match": "nightly-*", "channel": "webhook", "url": "${ENV:SLACK_WEBHOOK}", "cooldownMinutes": 15 },
{ "match": "*", "channel": "email", "smtpHost": "smtp.example.com",
"from": "duckle@example.com", "to": ["oncall@example.com"] }
]
}
The webhook payload carries a text field as well as structured fields, so Slack, Teams and Discord render it directly. Three behaviours are deliberate:
cooldownMinutes (default 15) bounds it per pipeline and event.recovery event and ignores the cooldown, so nobody is left thinking an outage is still running. Ordinary successes are silent unless you add "on": ["success"].A schedule whose pipeline file has been renamed or deleted also raises an alert, instead of silently doing nothing.
Pipelines can run on cron, fixed interval, or file-watch triggers. Configure these in the Schedule panel (toolbar -> Schedule icon), not as graph nodes.
| Trigger type | Config | Example |
|---|---|---|
| Cron | Standard 5-field cron expression with optional timezone | 0 2 * * * (every day at 2 AM) |
| Interval | every N {seconds, minutes, hours, days} | every 15 minutes |
| File watch | Watch a directory for new/changed files matching a glob | /inbox/*.csv |
| Manual | Run-on-demand only (the default) | - |
Schedules persist to workspace/schedules.json and execute via the in-process scheduler crate. They survive app restarts but require Duckle to be running.
For headless / always-on schedules that run when Duckle is closed, build the pipeline into a standalone file and let the operating system's own scheduler run it - see Server deployment below.
Describe what you need. Duckie writes the pipeline.
The sidebar on the right is Duckie AI Assistant - powered by Qwen 2.5 Coder 1.5B running through llama.cpp, downloaded once (~1.1 GB) and then run entirely on your CPU. Ask in plain English; Duckie streams back a valid Duckle pipeline definition. One click drops it onto the canvas, ready to inspect, tweak, and run.
| Truly local | The Qwen model runs as a llama-server subprocess on 127.0.0.1. No API keys. No network calls. Disconnect your wifi and it keeps working. |
| Streamed responses | Tokens arrive as they're generated, with a blinking caret in the bubble. No "wait 20 seconds for the spinner to vanish" UX. |
| One-click insert | When Duckie produces a JSON pipeline, an Insert into canvas button appears. The graph populates with positioned nodes, wired edges, and the props the model chose. |
| Bring-your-own-model option | The chat plumbing is the same OpenAI-compatible HTTP interface used by xf.ai.llm / xf.ai.embed connectors. Point baseUrl at Ollama, llama.cpp, Cohere, OpenAI, Voyage - anything that speaks the OpenAI shape. |
| Sandboxed | The model has no fs / net / tool access. It can only emit text - your pipeline JSON. |
The most common job in data engineering: load a 20M-row CSV into DuckDB. One identical 2.49 GB file (20M rows of TPC-H lineitem, 16 typed columns), every tool measured at its best configuration, wall-clock time to land the data as a table.
How it was measured
Why Duckle is this fast: its 15.69s sits right on top of raw DuckDB's own load floor (~16s to fully parse and write all 20M typed rows into an on-disk table). Duckle wraps the engine with pipelines, connectors, and a UI, then gets out of its way. That is the entire design goal. A read-only scan or aggregate over the same CSV is far faster still; this benchmark measures the heavier "materialize it as a table" job that every ETL tool here performs.
A second, harder job against a live database: full-refresh extract of 95,988,640 rows of TPC-H lineitem (14 GB in Postgres 16) out to Parquet.
Run it yourself. The harness is in this repo at benchmarks/pg-to-parquet. ./bench.sh all brings up Postgres, generates the data at any scale factor, and times every tool you have installed. No timing is recorded until the output has been reopened and checked for the right row count and the right sum(l_orderkey), so a tool that writes a fast but wrong file gets a failure rather than a number.
Read it with these caveats
postgres_scanner plus COPY TO: no scheduling, no typing, no incremental state, no UI. It is there to show how much of the clock is the machine reading Postgres. Duckle landing 11% under it is the honest framing, not "Duckle beats DuckDB".Hardware, per-run numbers and the two measurement traps that produced wrong figures on the first attempt are written up in RESULTS.md.
Duckle is in public beta. The visual designer, the DuckDB execution engine, the scheduler, the cloud connectors, and the Duckie AI assistant all work today and are covered by 170+ integration tests across Linux, macOS, and Windows. The catalog is still growing and APIs may evolve before 1.0, but the day-to-day surface is stable enough for real work.
Scope, stated plainly: Duckle runs as a service on hardware you provision, and uses all of it. What it does not do is split one query across a cluster, so when a job outgrows the largest instance you want to pay for, push the work down into the source system or point the output at a warehouse, object store or lakehouse. It will not pretend to be a cluster.
The component palette ships 384 nodes so the roadmap is visible in the product itself:
docs/roadmap.mdDuckle is not a CSV tool with extras. It reads a broad set of formats and sources, ships a deep transform library, and writes to files, databases, object storage, vector DBs, message buses, and email.
113 sources available today.
| Group | Connectors | Status |
|---|---|---|
| Files | CSV, TSV, Parquet, JSON, JSONL / NDJSON, Excel (.xlsx), YAML, TOML, Fixed-width (mainframe / banking positional dumps), XML (slash-separated rowPath), Apache Avro (.avro / .ocf, pure-Rust) | Available |
| Geospatial files | GeoJSON, Shapefile, GeoPackage, KML, GPX, GML via the spatial extension | Available (lazy-loaded) |
| File Geodatabase | Esri File Geodatabase (.gdb) feature classes via ST_Read with a per-layer selector | Available (lazy-loaded) |
| Hugging Face | Hugging Face Hub datasets over hf:// (Parquet / CSV / JSON, globs, revisions); token for private or gated datasets | Available |
| Geospatial | Read GeoJSON / Shapefile / GeoPackage / KML / GPX / Esri File Geodatabase; write those plus GeoParquet; CRS-aware measurement, reprojection, spatial joins and predicates | Available |
| Lakehouse table formats | Apache Iceberg, Delta Lake, DuckLake (catalog in a local file or a postgres: / mysql: / sqlite: DSN, with the catalog schema and META_* parameters - including META_SECRET - settable on the node) | Available |
| Embedded databases | SQLite (read tables), DuckDB (read tables or run a query) | Available |
| Network relational DBs | PostgreSQL, MySQL, MariaDB, CockroachDB | Available (live CI for PG + MySQL) |
| Network relational DBs | SQL Server (TDS), Oracle (Instant Client at runtime), ClickHouse (HTTP API), IBM DB2 (IBM Data Server ODBC driver), Turso / libSQL (HTTP pipeline API - no driver install; libsql:// URLs accepted) | Available |
| Network relational DBs | generic JDBC | Planned |
| Object storage | Amazon S3, Google Cloud Storage, Azure Blob, HTTP(S), MinIO, Cloudflare R2, Backblaze B2 | Available (live CI for MinIO) |
| Cloud warehouses | MotherDuck, Snowflake (SQL API + PAT/JWT), BigQuery, Redshift (postgres ATTACH), Databricks SQL (Statement Execution + chunk follow), Azure Synapse (TDS), Teradata (ODBC, Windows / Linux), DuckDB Quack (May 2026 remote protocol - HTTP on :9494, SECRET-based token auth) | Available |
| Streaming | Apache Kafka / Redpanda (pure-Rust rskafka), NATS JetStream, GCP Pub/Sub (REST + auto-ack), RabbitMQ (lapin AMQP), AWS Kinesis (HTTP + SigV4 - no AWS SDK), WebSocket (ws:// / wss://, optional subscribe frame) | Available |
| Streaming | Pulsar, Event Hubs, multi-shard Kinesis | Planned |
| APIs and SaaS (REST) | Salesforce, HubSpot, Pipedrive, Zendesk, Intercom, Stripe, QuickBooks, Xero, Shopify, Notion, Airtable, Asana, Trello, ClickUp, Monday.com, GitHub, GitLab, Linear, Jira, Slack, Discord, Telegram, Twilio, Mailchimp, SendGrid, Segment - thin pre-configured wrappers over src.rest / src.graphql. src.rest takes a configurable API-key auth header name and offset pagination that stops on a body total_count. Salesforce Bulk (src.salesforce.bulk) - Bulk API 2.0 query source for migration-scale reads: SOQL as an async query job (query / queryAll), paged CSV result sets streamed to disk via Sforce-Locator, typed empty relations on 0 records | Available |
| APIs (protocols) | OData v4 (follows @odata.nextLink), SOAP / generic XML APIs (XML response parsing with namespace local-name match) | Available |
| Health data (DHIS2) | src.dhis2 reads the DHIS2 Web API: aggregate dataValueSets, paged metadata lists, tracker exports, and analytics/dataValueSet.json. snk.dhis2 imports back: chunked requests, importStrategy (CREATE_AND_UPDATE is DHIS2's upsert), dryRun, and real import-summary parsing, so conflicts and a non-zero ignored count fail the run instead of passing as a green HTTP 200. Auth via personal access token or HTTP Basic. Raw /api/analytics (columnar headers[] + rows[][]) is not supported | Available |
| NoSQL and search | Neo4j (Cypher over the HTTP Query API - self-hosted or Aura, no Bolt driver; optional $parameters), MongoDB (official driver), Cassandra / ScyllaDB (CQL), Elasticsearch / OpenSearch (from+size + search_after), Redis (SCAN + GET), CouchDB (_all_docs), DynamoDB (HTTP + SigV4 - no AWS SDK; auto-unwraps typed attributes) | Available |
| Vector / AI databases | pgvector (postgres ATTACH), Qdrant (/points/scroll), Weaviate (/v1/objects), Milvus (/v1/vector/query) | Available |
| Vector / AI databases | Pinecone (no list-all-vectors API), Chroma, LanceDB | Preview |
| File transfer | FTP / FTPS (pure-Rust suppaftp) and SFTP (SSH, pure-Rust russh + russh-sftp on the ring backend; password or private-key auth) - one File Transfer component, pick the protocol. Glob filter, base64 content per file. Host keys are verified: pin a SHA256 fingerprint to accept only that key, or leave it empty and the first key seen for a host is recorded in <workspace>/.duckle/known_hosts, after which a different key is refused. A host that presents an OpenSSH certificate is accepted only when it certifies the key you pinned. DUCKLE_SFTP_HOST_KEY_POLICY=accept-any opts out for a host whose key changes per connection | Available |
| Mailbox | IMAP (rustls TLS, mail-parser) - basic auth today, OAuth (gmail / o365) on the roadmap | Available |
| Webhook listener | Binds 127.0.0.1:port, collects N inbound HTTP requests with a timeout, parses JSON-object / JSON-array bodies into rows | Available |
| Desktop | System clipboard (pure-Rust arboard, auto-detects JSON-array shape) | Available |
| Repos | Git (commit log or file tree from a local working copy; shells out to system git CLI) | Available |
For CSV / TSV sources, the Schema panel accepts an optional per-column Format (a strptime token string such as %d/%m/%Y) on Date and Timestamp columns. Several date columns can each parse a different layout in one read - the column is read as text and re-parsed with its own format, working around DuckDB's single global date format. A value that does not match its format becomes null rather than failing the run. Set a Date or Timestamp column's Format to excel to convert Excel day-serials correctly. CSV sources also surface ignoreErrors (skip unparseable rows) and nullPadding (pad short rows with nulls) toggles in the GUI.
For JSON sources, a Format selector picks how the file is read (auto / array / JSON Lines / object), and a skip malformed records toggle drops records that fail to parse instead of failing the run.
130 transforms available today.
| Group | Operations |
|---|---|
| Fields | Map (visual mapper: joins a main input to up to 3 lookup inputs with inner / left joins and per-output expressions + filter), Project / Select, Cast, Rename, Add / Drop / Reorder Column, Coalesce, UUID v4 |
| Rows | Filter (visual or raw SQL, with reject port), Distinct, Sample, Top N / Limit, Sort, Skip, Top N per Group, Forward Fill, Backward Fill, Constant Fill |
| Aggregate | Group By, Rollup, Cube, Count, Window Aggregate, Cumulative, Approx Quantile (t-digest), Approx Count Distinct (HyperLogLog) |
| Join | Inner, Left, Right, Full Outer, Cross, Lookup, Semi, Anti, Spatial Join (Intersects, Contains, Within, Touches, Crosses, Overlaps, Equals, Covers, Covered by; fails naming both systems when the two geometry columns use different CRS, rather than returning zero rows) |
| Set operations | Union, Union All, Intersect, Except / Minus |
| Window | Row Number, Rank, Dense Rank, Lead, Lag, First Value, Last Value, NTile |
| Strings | Regex Replace, Regex Extract, Regex Match, Split, Concat, Trim, Case Change, Length, Substring, Format, Hash (md5 / sha1 / sha256), IP Parse, URL Parse, Text Similarity (Levenshtein / Jaro-Winkler / Jaccard), Base64, Pad, Text Match |
| Date / Time | Parse, Format, Extract Part, Date Diff / Add, Truncate, Timezone Convert, Time Bin, Current Timestamp, Epoch Convert |
| Numeric | Round, Modulo, Absolute, Logarithm, Power, Square Root, Bucketize, Z-Score, Clamp, Sign |
| JSON / nested | Parse, Stringify, Flatten, JSONPath Extract, Merge Objects, Array Aggregate, jq Filter (a jq program per row over a JSON column, run in-process by the pure-Rust jaq engine - no external jq, no subprocess) |
| Array | Explode / Unnest, Collect List, Element At, Contains, Distinct, Length, Zip Arrays to Table (headings + row-arrays -> one column per heading) |
| Pivot / shape | Pivot, Unpivot, Denormalize, Normalize, Transpose |
| Quality gate | Every check offers On failure: reject (route the bad rows to the reject port, the default), warn, or fail (stop the run). fail raises where the rows are counted, so a gate asked to stop a load stops it |
| CDC / SCD | Incremental Load (watermark column; saves the high-water mark to workspace state and advances only on a fully successful run), Diff Detect, SCD Type 1, SCD Type 2 (valid_from / valid_to / is_current), Merge / Upsert (universal across embedded, network, warehouse and Mongo sinks, with optional delete propagation driven by a CDC change-type column), DuckLake CDC change-feed reader, Row Hash (md5 / sha1 / sha256 fingerprint), Audit Stamp (_loaded_at / _loaded_date / _source / _batch_id) |
| AI / Search | Vector Similarity Search (cosine / L2 / inner product over FLOAT[N] via vss), Full-Text Search (BM25 via fts), Embeddings (OpenAI-compatible /v1/embeddings), LLM Transform (per-row chat completion with {column} templates), Classify (LLM-backed, normalizes to UNKNOWN), Text Chunker (RAG-ready, pure local), PII Redact (regex - emails / phones / SSNs / cards), Semantic Dedupe (cosine over precomputed embeddings) |
| Geospatial | Spatial Distance, Length, Perimeter, Area (each auto-picks the planar or spheroidal function from the geometry CRS, and rejects geometry with no CRS), Spatial Buffer (ST_Buffer), Spatial Intersects (ST_Intersects), Flip Coordinates (ST_FlipCoordinates - fix lat,lon vs lon,lat order), Define Projection (ST_SetCRS - stamp a CRS without moving coordinates), Reproject Geometry (ST_Transform between CRS, target CRS preserved on the output), Create Geometry (from X/Y, WKT, or WKB), Clip Geometry and Erase Geometry (two-layer overlays; the second layer is dissolved with ST_Union_Agg so a feature spanning several polygons is not duplicated, and both refuse to run when the two layers carry different CRS) |
| Debug | Log Rows, Assert (hard-fail on SQL predicate violation) |
All 6 AI transforms ship today. Three need a model API (LLM, Classify, Embeddings) and ride the apiKey-in-props pattern; three are pure-local (Chunk, PII Redact, Dedupe).
27 validators available today.
Validators split their input: passing rows continue on the main port, failures route to a reject port you can sink, count, or inspect.
| Component | Behavior |
|---|---|
| Not-Null Check | Pass rows with no nulls in the chosen columns |
| Range Check | Pass rows inside a numeric range (inclusive or exclusive) |
| Regex Match | Pass rows whose column fully matches a pattern |
| Uniqueness Check | Pass the first row per key; route duplicates to reject |
| Schema Validate | Reject rows where any expected column is null |
| Column Profile | Per-column stats (count, null %, distinct, min / max, quartiles) via SUMMARIZE |
| Describe | Column names + types of the input |
| Histogram | Value frequencies for one column, most-frequent first |
| Standardize | Trim + case-normalize + collapse inner whitespace, in place |
| Fuzzy Deduplicate | Keep the first row per near-duplicate cluster |
| Record Match | Self-join: emit pairs of rows above a similarity threshold |
| Expectation Suite | A reusable suite of rules plus a data-quality scorecard, so one node carries the whole expectation set |
| Data Contract | Enforcement gate holding the same rule suite, failing the run when the contract is broken |
| Freshness / SLA | Age is now minus the newest value in a column, checked against a maximum you set |
| Outlier Detection | Statistical outlier detection; inliers continue, outliers route to reject |
| Referential Integrity | Orphan check across two inputs: rows whose key is absent from the reference route to reject |
| Reconciliation | Source-versus-target report, for proving a load matches what it came from |
| Record Linkage | Fuzzy linkage across two inputs, matching records that are not identical |
| Match Grouping | Turns matched record pairs into stable clusters, so a chain of matches becomes one group |
| Survivorship | Collapses duplicates sharing a group key into a single surviving record |
| Mask / Anonymize | Irreversibly masks or anonymizes selected columns in place |
| Column Classification | Heuristic column classification and PII tagging. No LLM, no data leaves the machine |
| Advanced Column Profile | A richer single-column profile than Describe |
| Reproducible Sample | A repeatable random sample of the upstream rows |
| Validate Geometry | Flags invalid geometries with ST_IsValid |
| Repair Geometry | Replaces the geometry column in place with a repaired one |
| Check Empty Geometry | Flags empty geometries with ST_IsEmpty |
| Address Cleanse | Address parsing / normalization (planned - needs external lib) |
7 ways to drop into code available today.
| Capability | What it does |
|---|---|
| Inline SQL | Write a SELECT; the upstream node is exposed as input, result runs as a real materialized stage. A raw SQL mode runs verbatim SQL (a leading WITH / multiple CTEs / UNIONs) with no input-CTE wrapper |
| SQL Template | Parameterized SQL with ${context.var} substitution |
| SQL Routines | Reusable, named SQL saved in the workspace |
| dbt | Run a dbt project (or one inline model) as a node, against the pipeline's DuckDB. Wire several upstream sources in and the project reads them all via dbt sources, so one project models across Postgres, MySQL, files, and lakes at once. Powered by the dbt Fusion engine, fetched free at first launch (Apache dbt-core fallback); no Python setup. |
| Shell | Run any shell command; emits {stdout, stderr, exit_code, duration_ms}. Platform-aware default shell. Optional timeoutMs kills the child. |
| WebAssembly UDF | Per-row WASM transform via pure-Rust wasmi. Sandboxed (no fs / net / env). Works with any WASM toolchain (Rust, AssemblyScript, C, TinyGo). |
| JavaScript UDF | Per-row JS transform via pure-Rust boa interpreter. Sandboxed. Define a transform(row) function. |
| Python / Rust UDFs | Embedded-language stages |
73 sinks available today.
| Group | Connectors | Status |
|---|---|---|
| Files | CSV, TSV, Parquet (ZSTD), JSON, JSONL / NDJSON, Excel (.xlsx), YAML, TOML, XML (configurable wrappers), Avro (schema inferred from first row). Parquet + CSV support Hive-partitioned writes | Available |
| Geospatial files | GeoJSON, GeoPackage, Shapefile, KML, GPX via GDAL | Available (lazy-loaded) |
| Lakehouse | Apache Iceberg (full table layout), DuckLake - modes: overwrite, append, truncate, upsert (set-based delete-by-key + re-insert), merge (partial-column MERGE INTO that preserves columns the source omits) with optional CDC delete propagation, plus publish groups - several DuckLake sinks sharing a group name commit as one snapshot, so readers see all of their tables update together or none of them, and a run that cannot honour the group is refused rather than publishing part of it | Available |
| Embedded databases | SQLite, DuckDB - modes: overwrite, append, upsert (set-based delete-by-key + re-insert, no PK required), merge (partial-column MERGE INTO that preserves columns the source omits) with optional CDC delete propagation | Available |
| Network relational DBs | PostgreSQL, MySQL, MariaDB, CockroachDB - modes: overwrite, append, truncate, upsert (ON CONFLICT / ON DUPLICATE KEY) with optional CDC delete propagation | Available (live CI for PG + MySQL) |
| Network relational DBs | SQL Server / Azure Synapse (TDS, multi-row VALUES batched; auto-creates the table if absent; upsert via MERGE), Oracle (Instant Client; INSERT ALL, batched per statement; auto-creates the table if absent; upsert via MERGE), ClickHouse (HTTP JSONEachRow; upsert by pointing at a ReplacingMergeTree target table), IBM DB2 (ODBC; auto-creates the table, booleans as SMALLINT 1/0 so DB2 for z/OS also accepts them), Turso / libSQL (HTTP pipeline API; auto-creates the table, values sent as bound parameters) - every MERGE sink supports CDC delete propagation (a delete-flag column removes matched rows) | Available (SQL Server + Oracle + MySQL upsert and delete propagation verified live in Docker) |
| Network relational DBs | generic JDBC | Planned |
| Object storage | S3, GCS, Azure Blob via DuckDB httpfs (MinIO / R2 / B2 via endpoint) | Available |
| Hugging Face | Push to a Hugging Face Hub dataset repo (snk.huggingface): the upstream is materialized to Parquet and committed over the Hub API (create-repo → preupload → git-LFS → commit); write token required, repo auto-created (public or private) | Available |
| Cloud warehouses | MotherDuck, Snowflake (PAT or JWT RS256; upsert + delete propagation via MERGE), BigQuery, Redshift, Databricks SQL (upsert + delete propagation via MERGE), Azure Synapse, Teradata (ODBC), DuckDB Quack (concurrent writers to remote DuckDB via the May 2026 protocol) | Available (Snowflake MERGE verified live against the SQL-API emulator) |
| HTTP APIs | REST (POST/PUT/PATCH batched JSON-array; configurable API-key auth header name), Webhook (one POST per row), GraphQL mutations | Available |
| SaaS / CRM | Salesforce (snk.salesforce) - sObject Collections API: insert / update / upsert (by external Id) / delete, ≤200 records/request, Bearer token or OAuth 2.0 client-credentials (fresh token minted per run, same auth as src.salesforce). Salesforce Bulk (snk.salesforce.bulk) - Bulk API 2.0 for migration-scale loads: insert / update / upsert / delete / hardDelete, DuckDB streams to CSV and each ≤90 MB part runs as an async job | Available |
| Email (SMTP) | Per-row SMTP send via pure-Rust lettre + rustls. Plain text v1; HTML + attachments follow. | Available |
| NoSQL | Neo4j (rows as nodes over the HTTP Query API; one UNWIND $rows round trip per batch, mergeKeys switches CREATE to MERGE so re-runs update rather than duplicate), MongoDB (insert_many batched; upsert via replace_one on a key, plus delete propagation via delete_one), Cassandra / ScyllaDB (CQL), Elasticsearch / OpenSearch (_bulk NDJSON), Redis (pipelined SET) | Available |
| NoSQL | DynamoDB | Planned |
| Streaming | Kafka / Redpanda (rskafka), NATS JetStream, GCP Pub/Sub (REST + OAuth2), RabbitMQ (lapin), WebSocket (ws:// / wss://) | Available |
| Streaming | Pulsar, Kinesis | Planned |
| Vector / AI databases | pgvector, Pinecone (/vectors/upsert), Qdrant (/points PUT), Weaviate (/v1/batch/objects), Milvus (/v1/vector/insert) | Available |
| Vector / AI databases | Chroma, LanceDB | Preview (need vendor SDK) |
Database sinks support an optional dead-letter (validate-before-insert) step: rows that do not match the declared column types are split off to a dead-letter file (parquet / csv / json) and only the clean rows are inserted.
18 control-flow components available today.
| Component | What it does |
|---|---|
| Replicate / Tee | Send the same data to multiple downstream outputs |
| Merge Streams | Concatenate multiple input streams (UNION ALL) |
| Switch / Conditional Split | Route rows to case_1..N outputs by boolean (first match wins); default for unmatched |
| Wait / Delay | Sleep N ms / s / min / h before passing rows through |
| Throttle | Inter-stage delay derived from a rows-per-second target |
| Set Run Variable | Work out a value while the run is under way and let later steps read it as ${name} (ctl.setvar), in this pipeline and in the jobs it goes on to run. Wired to rows the expression is read against them and the first row decides, so use an aggregate to read the whole input; wired to nothing it stands on its own. The value is held in the run's own database, so it survives whichever way the engine executes the stages |
| Checkpoint | Pass rows through and also write a parquet snapshot to a path |
| Dead Letter Queue | Terminal sink for rejected rows (JSON / CSV / Parquet) |
| Run Pipeline | Inline-execute another pipeline file (ctl.runpipeline) |
| Run Job | Call a child pipeline (picked from the workspace) passing parent context variables; chain several to build a Master Job (ctl.runjob). The child runs for its side effects: it gets its own temporary database and its output is not composed back into the parent, so a child cannot yet return rows to its caller |
| Parallelize | Run the downstream branches wired to its outputs concurrently; branches are unlimited (ctl.parallelize) |
| Iterate | Run a sub-pipeline N times with ${ITER_INDEX} substitution |
| For Each | Run a sub-pipeline once per input row with ${ITER_ITEM_<FIELD>} substitution; an optional item key column names each run so per-row watermarks stay separate |
| Try / Catch | Install a fallback sub-pipeline if the wrapped stage fails |
| Retry | Per-stage retry policy (configure on Advanced tab) |
| Log Message | Emit an info log line ({rows} = upstream count), pass rows through (ctl.log) |
| Warn | Emit a warning log line, pass rows through (ctl.warn) |
| Die / Fail | Stop the run with a message: always, only when the input has rows, or only when empty (ctl.die) |
| Schedule | Cron / interval / file-watch triggers via the orchestration crate |
A run variable is read as a value wherever the SQL of a later step names it: on its
own, as a whole string literal ('${name}', the usual way to write a value into a
WHERE clause, where the quotes come off with it), or inside a longer literal, which
is joined around it. A name a node sets this way is left for the run to fill in, so a
static context entry of the same name does not pre-empt it.
It also travels into whatever the pipeline runs. A Run Job, an Iterate or a For Each
started after the value was worked out hands it to the child as ${name}, and on again
to whatever that child runs, so a value settled in a master job reaches a body several
levels down. A value named on the call itself still wins, since naming one there is how
a parent says which value to run the child with. A name whose value came out NULL is
not passed: it has no value, and an unset ${...} is left as it is rather than arriving
as the word NULL.
A sub-pipeline runs under its own name, so its run log lands in
logs/<child>/ and an xf.incremental watermark inside it is saved to
state/<child>/<node>.json. Two different children driven by the same For Each
therefore keep separate marks.
Set For Each -> Item key column to separate the ITERATIONS too. The child
then runs as <child>@<value>, so loading 400 tables through one sub-pipeline
keeps 400 watermarks in state/<child>@<table>/<node>.json instead of one.
Leave it blank and every row shares a single mark, which silently skips rows
when each row is a different table. It is never inferred from the row's
position, because that would move every watermark the moment the driving query
is reordered.
Set For Each -> Dispatch to Queue for workers and the rows are written to
batches/<id>.ndjson instead of being run, one JSON line per row carrying the
child reference and that row's substitutions. Nothing runs until a worker picks
the batch up, so the run that queued it reports how many items are waiting
rather than pretending they loaded. A batch is a file in the workspace like
everything else here: no queue server, no database, no network service.
Queueing also reports whether the items are actually safe to spread out. Both "400 items each loading their own table" and "400 items appending to one file" look identical on the canvas - one sink node with a variable in the path - and only the first survives being run at once. So each item's variables are put into the child and the resulting targets are named with the same function that builds the workspace catalog, before anything picks the batch up:
duckle: 400 item(s) write to targets nothing else in the batch writes, so they
are safe to run at the same time
duckle: heads up - 1 target(s) are written by more than one item (400 items
write /lake/everything.parquet). Workers run items at the same time, so
these will collide unless the sink is an upsert or the target is
append-safe
It warns rather than refuses: appending many items into one table is a real thing to want, and only you know whether that sink is safe for it. Items whose child cannot be read are counted and reported, so a partial check never reads as a clean one.
Then run workers against it:
duckle-runner work --workspace /path/to/workspace # drain every batch
duckle-runner work --batch fe-20260816T101112123 # just this one
duckle-runner work --once # one item, then exit
duckle-runner work status # what is stuck, and why
duckle-runner work retry --dead # start the stuck ones over
Start it on several machines pointed at one workspace and they share the batch.
Each item is claimed with the same OS lock a pipeline run uses, so no two
workers take the same one, and a worker that is killed mid-item leaves nothing
to clean up: the kernel drops the lock and the item becomes claimable again.
There is no lease, no heartbeat and no timeout, because there is nothing to
expire. Progress is appended to batches/<id>.ledger.ndjson, so re-running a
worker resumes rather than repeats.
Retries are bounded. A failed item stays claimable and is tried again on a
later pass, which is right for a timeout and wrong for a 404 that will always be
a 404: without a limit that item takes a worker slot on every pass forever. Set
Max attempts per item on the For Each node, with a fixed or exponential
backoff, and an item that uses them up is left alone and reported as dead rather
than chased. work status lists what is waiting out a backoff and what is dead,
with the last error; work retry --dead starts the dead ones over. A retry
appends a reset marker rather than rewriting the ledger, so the failures stay
readable - an item that died four times before someone fixed the source still
says so. Leave max attempts at 0 and behaviour is exactly what it was.
Items run at least once, not exactly once. The ledger is written after an item succeeds, so a worker that finishes an item and then dies leaves it looking undone and another worker repeats it. That is the honest trade for having no transactional store - the alternative loses items instead of repeating them, and a lost load is worse. Make the child idempotent (an upsert sink rather than an append) and a repeat costs time, not correctness. A failed item stays claimable and is retried on a later pass, with the failure kept in the ledger.
The console has a Batches view: progress per batch, how many items are running right now, how many failed, and the recent attempts with the worker that ran each one. "Running" is answered by asking the run lock rather than by trusting a heartbeat, so a worker that died is not counted as running and there is no lease that could have gone stale. Retry failed clears the recorded failures so those items are claimable again, keeping the successes so a retry never repeats finished work.
Before running anything, a worker proves the lock actually excludes on that
filesystem: it takes a lock and asks a second process whether it can take the
same one. Some shared filesystems tell every caller it has the lock - NFS with
no lock daemon is the classic case - and on one of those every worker would
claim every item and each item would run once per worker, silently, with no
error anywhere. A worker refuses to start there. Check it yourself with
duckle-runner work --check; --no-check overrides, knowing the above. A test
that could not be run is only a warning, because failing to prove exclusion
is not the same as having disproved it.
Measured on one machine: three workers against a twelve-item batch took four
items each, with no item run twice. Several machines against one shared
filesystem is the design intent and is not yet measured, so treat it as
untested until it is. scripts/measure-multi-host-batch.sh is the measurement:
point it at a shared workspace and two or more hosts and it counts duplicate
executions, failing if there are any.
Every node has an Advanced tab with fields the engine honours at run time:
| Field | What it does |
|---|---|
| Retry attempts | Total tries on failure (1 = no retry). Sleeps backoff * attempt ms between attempts. |
| Retry backoff (ms) | Inter-attempt sleep, linearly scaled by attempt index. |
| Memory limit (MB) | PRAGMA memory_limit applied to this stage only. |
| Log row count | Print the post-stage rowcount to the run output. |
| Capability | What it does |
|---|---|
| Run feedback | Streaming run events light nodes up stage by stage, with per-node row counts, real mid-query cancel, and run history. |
| Error traceback | A failed stage reports the exact compiled SQL plus the underlying DuckDB message, in both the Run view and the NDJSON run log, so any component's failure is debuggable. |
| Column lineage | A top-bar Lineage button shows, per node, each output column traced back to the source column(s) it derives from. |
| Dives + dashboards | Live-querying, shareable data views that run where your data already is, stitched into multi-chart dashboards. Generate a chart from a plain-language question, export a dive to a self-contained HTML file, open standalone /dive/<id> and /dash/<id> share pages, and find everything in the top-bar Dives gallery. |
| Artifacts | src.artifact gives one row per file described the way a pipeline can reason about it - uri, name, media_type, size_bytes, sha256, modified_at - for PDFs, images, archives, OCR output and model binaries. An artifact is a reference, not the bytes, so it joins, filters and iterates like any other table. Hashing is off by default because it reads every byte |
| Python, row or table | code.python takes process(row) for a row at a time, or transform(table) to be handed the whole table as a pyarrow Table - for polars/pandas work, OCR, entity resolution or ML. The table path goes through Parquet rather than JSON: measured 2.11s -> 0.74s on 200k rows, and it keeps types, where the row path turns every timestamp into a string. Needs pyarrow only when transform is used |
| A workspace's own Python | A Python stage is only reproducible if the packages it needs travel with the pipeline rather than being whatever the machine happens to have. Put a virtual environment at .venv in the workspace - uv venv && uv pip install pyarrow polars, or the stdlib python -m venv - and code.python uses that interpreter on every machine, laptop, CI and headless runner alike. Nothing is installed at run time, so an air-gapped box stays air-gapped, and DUCKLE_PYTHON_BIN still overrides everything |
| Batch inference that survives a rate limit | xf.ai.llm, xf.ai.classify and xf.ai.embed take Parallel requests and Retries on rate limit. A 429 or 5xx is now retried per request, honouring Retry-After, instead of failing the stage: before this, one rate limit at row 400,000 threw away the 399,999 rows already paid for, because the only retry in the engine re-runs a whole stage from row 0. Requests run up to Parallel requests in flight and results are written back by index, so the output row order is still the input row order. Both default to today's behaviour (1 in flight, 3 retries). xf.ai.llm also finally sends Max tokens, a field the panel has offered since v0.5.4 while the request never carried it |
| Blocking for entity resolution | Every fuzzy match compares pairs, and comparing all of them grows with the product of the row counts, so linking 100k records against 100k is 10 billion comparisons. qa.block proposes only the pairs worth comparing: named rules of columns that must be equal (same postcode, same surname initial), each one pass, a pair caught by several rules still emitted once. One input dedupes within a table, the lookup port links two. It emits id_a, id_b, blocking_rule and a_<col>/b_<col> for carried columns, which is exactly what qa.matchgroup reads by default, so blocking, comparison with xf.addcol, banding with ctl.switch and clustering chain up out of components that already exist |
| One REST node per parent row | Real APIs are rarely one endpoint: /companies gives you ids, and the data you want is at /companies/{id}/officers. Give src.rest a URL per upstream row and wire a parent into its input, and it makes one request per row, substituting {column} from that row, unioning every result into its one output table. The shared connection, the single OAuth mint, the auth headers and all five pagination strategies are reused per request rather than re-done. Carry upstream column stamps the parent's key onto each child row so the two can be joined back together, and chaining three nodes main-to-main gives three real relations rather than one opaque loop. Unwired, the node is the plain source it has always been |
| Runs that outlive the request | A backfill can run for hours, and a synchronous HTTP call is the wrong place to keep it: clients, proxies and load balancers all time out while the pipeline is still legitimately working. POST /api/run/async answers 202 with a runId straight away; GET /api/run/status?runId= reports queued, running or finished with the pipeline's own status; DELETE /api/run?runId= cancels, which is polled at every stage boundary and kills the active DuckDB child so even a long query stops promptly. Every run record now carries the id it was accepted under, so a console that restarted mid-run can still answer for it. POST /api/run is unchanged for anything that wants to wait |
| HTML as a source | A great deal of public data is published only as HTML: registries, filing pages, results tables. src.html reads a local file or an http(s) URL and turns it into rows by CSS selector. Name a column per sub-selector (a@href reads an attribute), or leave the columns empty and let a table be a table: the th cells name the columns and each tr is a row. Parsed with a tolerant HTML parser, so the unclosed tags and unquoted attributes real pages carry - and that the strict XML reader rejects outright - are fine. A selector that does not parse fails the run naming it, rather than quietly producing a table of nulls |
| HTTP transport, set once | Proxies, timeouts and a User-Agent are transport, not credentials, and every HTTP-backed component wants the same ones. A saved HTTP transport connection carries a proxy, a read timeout, a connect timeout and a User-Agent, and src.rest and src.html reference it alongside their auth connection, so a corporate proxy is one edit rather than one per node. What a node sets itself still wins. Every request in the engine also now has deadlines: a connect timeout of 30s and a read timeout of 300s, both overridable with DUCKLE_HTTP_CONNECT_TIMEOUT and DUCKLE_HTTP_READ_TIMEOUT. They are per-read, not per-transfer, so streaming a large file is unaffected while a dead socket can no longer park a stage indefinitely - which matters more now that AI stages keep several requests in flight |
| PDF pages as rows | A great deal of data engineering starts from documents, not tables: filings, annual accounts, invoices, regulatory publications. src.pdf gives one row per page - document_id, page_number, text, has_text_layer, width, height and the document's own metadata - from a file or a whole folder, using the text layer the PDF already carries. document_id is the same value src.artifact puts in uri, so a file listing and its pages join without translation. There is no OCR, deliberately: rasterising a scanned page needs a native rendering engine and per-language trained data, which would end the self-contained cross-OS build. A scanned page arrives with has_text_layer false instead, which is what lets you filter those pages out and route them to whatever OCR you already run |
| Model cards, not a model store | Once a pipeline can train a model it needs to answer which model produced this output, and where it lives. snk.model records a card - the artifact URI your training script wrote, plus whatever metrics, framework and hashes it reported - to <folder>/<name>/<version>.json, with a latest.json pointer beside it; src.model reads one back as a row, addressed as name@version or name@latest. The engine never touches the model bytes and never loads a model: the row carries the URI and your Python stage does the rest. What it does add is the part a convention cannot - the card is written only if the whole run succeeded, so a training pipeline that fails afterwards never registers a model and a failed retrain never moves the pointer off the model that still works |
| Kafka security that is actually applied | The Kafka form has offered a security protocol, a SASL mechanism, a username and a password since the connector shipped, and the engine read none of them: a node configured for SASL_SSL connected in plaintext, unauthenticated, and said nothing about it. All four are now honoured - TLS reuses the same merged OS-plus-bundled trust store every other connection uses, and PLAIN, SCRAM-SHA-256 and SCRAM-SHA-512 are supported. A mechanism outside that set fails the run naming what is available, rather than quietly downgrading to an unauthenticated connection. Consumer group has been removed: the Kafka client Duckle uses implements no consumer groups, so it could never have done anything - use Resume where the last run stopped instead, which is the job it looked like it was doing |
| Kafka that resumes | Tick Resume where the last run stopped on a Kafka source and it remembers the offset it reached, carrying on from there next run. That is what turns a schedule into a stream: without it, Earliest re-reads the whole backlog every run and Latest skips everything that arrived in between, so repeated runs could never be stitched together. The position is written only when the whole run succeeded, so a failure after the read re-delivers those records rather than losing them - at-least-once, deliberately, since the alternative is committing an offset for rows no sink ever wrote. A saved position records the topic and partition it belongs to and is ignored if either changes |
| Response provenance | Tick Add response metadata on a REST source and every row carries _http_url (the exact URL fetched, per page), _http_status and _fetched_at, so you can tell whether a result changed because the source changed or because the parser did |
| Reuse a stage's output | Tick Reuse this stage's output on an expensive deterministic stage and it writes its rows once, then reads them back while its SQL, everything above it, and the size/modified time of any local file it reads are unchanged. Off by default and per stage: a cache that guesses when it is still valid serves stale rows silently. rm -r .duckle/duckle_cache clears it |
| Pipeline tests | duckle test runs a pipeline against a fixed input and asserts the rows out of one node. validate catches what will not compile; this catches a transform that compiles and computes the wrong thing. A case stops at the node it asserts on, so no sink writes. Exit 1 on a failed assertion, so CI gates on it |
| Run to a node | duckle-runner --target <node> stops at that node and prints its rows; the MCP run_pipeline tool takes the same target. Nothing downstream runs, so no sink past it writes - the run-from-here the desktop preview uses, for checking one step without executing the rest |
| Run logs | Every run writes component-level NDJSON to <workspace>/logs/<pipeline name>/runtime.log (start/finish per stage, row counts, durations, ctl.log / ctl.warn / ctl.die messages). Tail it straight into Splunk or Dynatrace. |
| Schedules | Cron, fixed-interval, and file-watch triggers, driven by an in-process scheduler. |
| Context variables | Per-environment variables; bind any field to one via a Manual / Context dropdown, or reference ${var} inline. Resolved at run time. |
| Workspace-relative paths | Built-in ${workspace} (alias ${projectroot}) resolves to the active workspace root, so source / sink paths can be written relative to it and a workspace folder stays portable when copied or moved. No context needed; works in the canvas, schema autodetect, and headless runs. |
| Run-time path placeholders | Built-in ${date}, ${time}, ${datetime}, ${timestamp}, and ${now} (UTC) stamp the current run time into any path. They resolve fresh on every run (canvas, schedule, headless runner, built bundle), and a sink's parent folder is created automatically, so a path like ${workspace}/exports/${date}/orders.parquet lands in a new dated folder each day. No context needed. |
| Cloud credentials | Saved S3 / GCS / Azure connections become DuckDB SECRETs; cloud reads / writes go through httpfs. S3-compatible endpoints (MinIO / R2 / B2) supported via ENDPOINT + URL_STYLE. |
| Workspace | Pipelines, connections, contexts, documents, and routines persist as plain JSON and Markdown files in a folder you choose. |
Models inherit the quality of their inputs. RAG indexes, embedding stores, and training sets quietly accumulate duplicates, nulls, malformed rows, mixed encodings, and inconsistent schemas. Duckle is built to scrub that data before it lands in a vector store:
xf.ai.dedupe over a precomputed embedding columnxf.ai.pii before embeddingxf.ai.chunk -> xf.ai.embed for RAG indexingxf.ai.classify constrains the model to one of N user-supplied categories)Duckle ships a thin shell and installs its engines on first launch.
| Engine | Role | Status |
|---|---|---|
| DuckDB | Default execution engine: analytics, file formats, cloud reads, SQL pushdown. Tracking v1.5.3 (latest stable). A lock-free single-SELECT read (Engine::query) powers dives. | Working |
| Duckie AI Assistant | Local chat assistant via llama.cpp + Qwen 2.5 Coder 1.5B GGUF. Downloads ~1.1 GB and needs no network once installed, or point it at your own OpenAI-compatible endpoint and skip the download entirely. Managed as a llama-server subprocess exposing an OpenAI-compatible API on 127.0.0.1. | Installable |
| SlothDB | Alternate embedded analytical engine (SouravRoy-ETL/slothdb), installed the same way and selectable per pipeline. | Installable |
| Native | In-process Rust streaming / incremental engine. | Planned |
When the installer downloads the DuckDB CLI it also pre-fetches the extensions Duckle uses, with per-extension progress, so the first time you touch a Postgres source or an Iceberg table there is no surprise network hop mid-pipeline:
httpfs (S3 / GCS / HTTP), azure (Azure Blob native), sqlite, postgres, mysql, excel, iceberg, delta, ducklake, vss, fts.
spatial is lazy-loaded (~50 MB GDAL bundle) - it installs on first use of a geospatial source/sink to keep the initial download small.
A wider tour of the workflow.
| Step | What you do | Where to look |
|---|---|---|
| 1. Sources | Drag a source, point it at a file / DB / cloud URL / SaaS endpoint. Click Autodetect schema to read columns + a sample. | Sources reference |
| 2. Transforms | Wire transforms to source output ports. Configure in the Properties panel. Preview tab shows live rows; Plan tab shows generated SQL. | Transforms reference |
| 3. Data quality | Drop in a validator (Not-Null, Range, Regex, Uniqueness). Passing rows continue on the main port; failures route to the reject port. | Data quality reference |
| 4. Sinks | Finish with a sink (file, DB, cloud, vector DB, message bus, email). Set write mode (overwrite, append, truncate, upsert). | Sinks reference |
| 5. Run | Press Run to execute on DuckDB. Nodes light up stage by stage; Output + Console show row counts, timing, errors. Stop button kills mid-run. | Run feedback |
| 6. Ask Duckie | For anything you can describe in English, the AI assistant can sketch a pipeline. Iterate by editing the graph or asking follow-ups. | Meet Duckie |
| 7. Reuse | Save Connections, Context variables, and SQL Routines in the workspace; reference ${context.var} in any field. Everything persists as plain files. | Workspace and Git flow |
| 8. Schedule | Attach a cron, interval, or file-watch trigger to run a pipeline automatically. | Schedules and triggers |
Ready-to-adapt patterns. Each one is a few nodes you wire on the canvas (or ask Duckie to sketch).
"Read orders.csv, drop nulls, deduplicate by order_id, write to orders_clean.parquet"
src.csv -> qa.not_null -> qa.uniqueness -> snk.parquet
Set qa.not_null to the columns that must be present; set qa.uniqueness to order_id. Rejected rows go to a snk.csv on the reject port for inspection.
"Read all rows from Postgres
events, upsert into Snowflake tableanalytics.eventsonevent_id"
src.postgres -> snk.snowflake (mode=upsert, conflict=event_id)
Attach a ctl.schedule with cron 0 2 * * * to run nightly at 02:00.
"Read all .json.gz files in
s3://logs/2026/*/*.json.gz, parse, write Hive-partitioned byevent_date"
src.s3 (glob, autodetect json.gz)
-> xf.derive (event_date = CAST(ts AS DATE))
-> snk.parquet (path=out/, partitionBy=event_date, mode=overwrite_or_ignore)
"Chunk our docs, embed with OpenAI, dedupe near-identicals, store in pgvector"
src.s3 (markdown files)
-> xf.ai.chunk (chunkSize=1500, overlap=150)
-> xf.ai.pii (redact)
-> xf.ai.embed (model=text-embedding-3-small, baseUrl=https://api.openai.com)
-> xf.ai.dedupe (threshold=0.95)
-> snk.pgvector (table=docs)
"Pull yesterday's Slack messages from #support, classify by sentiment, email a summary"
src.slack (channels.history with oldest=yesterday)
-> xf.ai.classify (categories=positive,negative,neutral)
-> xf.aggregate (group by sentiment, count)
-> snk.email (to=oncall@..., subject=Daily Support Digest)
"Receive 100 webhooks, archive each one as JSON in S3"
src.webhook (port=8080, maxRequests=100, timeoutMs=300000)
-> snk.s3 (path=s3://archive/events/, format=jsonl, partitionBy=event_date)
"Build a dashboard of who's been committing what in the last 30 days"
src.git (mode=log, maxRows=10000)
-> xf.filter (date > current_date - INTERVAL '30 days')
-> xf.aggregate (group by author_email, count)
-> snk.csv (path=author-stats.csv)
More examples live in samples/ - drop the pipeline files into a workspace and open them.
Push, pull, branch, and watch CI from inside Duckle. No terminal required.
Click the Git icon in the topbar to open the workspace Git panel. Built-in integration with GitHub and GitLab, on the system git CLI (no FFI, no embedded git library):
| Feature | What it does |
|---|---|
| Status snapshot | Current branch, ahead/behind counts, list of modified / staged / untracked / conflicted files |
| Stage all + commit | One-click git add -A && git commit -m "..." with your message |
| Push / Pull | git push and git pull --ff-only against origin. The button stays disabled when there's nothing to push |
| Branch list, switch, create | Lists local branches; click to switch; create new branches inline |
| Remote URL config | Add or change origin URL from inside the panel - auto-detects GitHub vs GitLab from the host |
| PAT-prompt fallback | First tries git push using your system credential helper (GitHub CLI, osxkeychain, manager-core). On a 401, prompts for a Personal Access Token, saves it AES-encrypted in <workspace>/.duckle/secrets/git.json (auto-gitignored), retries with the token injected into the HTTPS URL |
| CI build badge in topbar | Polls GitHub Actions or GitLab CI every 30 s for the latest pipeline on your current branch. Shows green / red / yellow / gray. Click to open the build in your browser |
Workflow. Workspaces are plain folders (see Workspace and Git flow) - any standard Git workflow works:
Create / clone -> open in Duckle -> edit pipelines -> commit + push ->
PR / MR -> CI runs your pipeline tests -> merge -> pull
You can do the entire push / pull / merge loop without leaving Duckle. Heavy operations (interactive rebase, conflict resolution, log archaeology) still live in your terminal or external Git tool - the panel is designed for the everyday flow, not as a full Git replacement.
Provider detection. The remote URL host determines which CI API the badge polls:
| Provider | CI source | API |
|---|---|---|
github.com | GitHub Actions | GET /repos/{owner}/{repo}/actions/runs |
gitlab.com or self-hosted GitLab | GitLab CI | GET /api/v4/projects/{id}/pipelines |
| Other / bitbucket | (no CI badge for now) | - |
The badge uses the same PAT you saved for pushes - no separate auth step.
A workspace is a folder you pick on first launch. Everything you build lives there as plain text:
my-workspace/
pipelines/
orders_etl.pipeline.json # the node graph
nightly_load.pipeline.json
connections/
prod-postgres.connection.json # saved DB credentials (encrypted)
snowflake-analytics.connection.json
contexts/
dev.context.json # variables for dev environment
prod.context.json
routines/
cleanse-addresses.sql # reusable SQL snippets
documents/
runbook.md # plain-Markdown docs
schedules.json # all scheduled runs in this workspace
run-history/
orders_etl/ # one folder per pipeline
2026-05-25T14-30-00.json # one file per run
Git-friendly by design. Every file is human-readable JSON or Markdown. Standard workflows work:
git init my-workspace && cd my-workspace
git add . && git commit -m "Initial pipelines"
# Pull a teammate's update
git pull --rebase
# Push your changes
git push
# Branch for a risky migration
git checkout -b feature/upsert-mode
# ...edit pipelines in Duckle...
git diff # readable JSON diffs
git push -u origin feature/upsert-mode
# open PR / MR
Sensitive values in connections get encrypted with a workspace-local key (workspace/.duckle/keys/). Don't commit that file - add **/.duckle/keys/ to .gitignore. The connection JSON files themselves only hold the ciphertext, which is safe.
Duckle ships its own Model Context Protocol server, so Claude (or any MCP client - Claude Desktop, Claude Code, Cursor, or any other LLM agent) can drive Duckle directly: browse the full component catalog and per-component property schemas, generate a pipeline straight into a working directory you choose, validate it (compile without running), run it headlessly, read existing pipelines and their run logs, build a standalone artifact, and manage saved connections.
If you have uv, one line connects any MCP client. Nothing is installed, no engine to configure: uv fetches the package and the DuckDB engine into a throwaway environment and the server finds it there.
claude mcp add duckle -- uvx duckle mcp
For Claude Desktop, Cursor, or any other client, the same thing as config:
{ "mcpServers": { "duckle": { "command": "uvx", "args": ["duckle", "mcp"] } } }
uvx duckle mcp works because the package and the command are both named duckle, so there is no --from to remember. If you would rather install it, pip install duckle puts duckle on PATH and the same duckle mcp command applies.
Then ask the agent something like "use duckle to list the available components". It can discover a real connector rather than guess one, compile-check a pipeline with validate_pipeline before anything executes, run it, and hand back column-level lineage. What it produces is the same JSON the canvas opens, so you can see what it built.
The MCP server is also bundled inside the app - there is nothing extra to install. In the designer, click Connect to Claude in the top bar to open the connector popup, then pick your client:
duckle server for you (runs
claude mcp add under the hood).duckle entry into
that client's config, with the resolved engine paths filled in (both the
Microsoft Store / MSIX and standalone Claude Desktop layouts are handled).Restart the AI client, then try "Use duckle to list the available components" to confirm the connection.
For a build-from-source or server setup, point any client at the duckle-mcp
binary directly. It speaks JSON-RPC over stdio and reuses the DuckDB engine
in-process (no GUI, no Node runtime).
cargo build -p duckle-mcp --release # target/release/duckle-mcp
claude mcp add duckle -- /path/to/duckle-mcp
For Claude Desktop and other clients, add it to mcpServers:
{
"mcpServers": {
"duckle": {
"command": "/path/to/duckle-mcp",
"env": {
"DUCKLE_DUCKDB_BIN": "/path/to/duckdb",
"DUCKLE_RUNNER_BIN": "/path/to/duckle-runner"
}
}
}
}
Tools: list_components, get_component_schema, create_pipeline,
validate_pipeline, run_pipeline, list_pipelines, read_pipeline,
read_run_logs, build_pipeline, list_connections, create_connection, backfill_list, backfill_set, backfill_clear.
run_pipeline / build_pipeline need a DuckDB binary (DUCKLE_DUCKDB_BIN);
build_pipeline also needs duckle-runner (DUCKLE_RUNNER_BIN). Full guide:
docs/current/mcp.md.
Saved connections become DuckDB secrets at runtime so credentials never leak into the pipeline JSON.
| Type | Stored fields | Used by |
|---|---|---|
| PostgreSQL / MySQL / etc. | host, port, user, password, database, ssl mode | src.postgres, snk.postgres, ... |
| Snowflake | account, user, role, warehouse, PAT or JWT private key | src.snowflake, snk.snowflake |
| S3 / GCS / Azure | access key, secret, region (or service-account JSON) | All cloud sources/sinks via httpfs |
| MotherDuck / Databricks / BigQuery | token, workspace URL | Respective sources/sinks |
| Generic REST / SaaS | base URL, headers, auth scheme (Bearer / Basic) and token | All REST aliases |
Connections live in workspace/connections/ as JSON. The token/password field is encrypted with the workspace key; the rest is plain text.
To use a connection in a pipeline, the Properties panel of any compatible source/sink shows a Connection dropdown - pick one and the fields auto-fill. The list is filtered to connections of a matching kind, so a REST connection is not offered on a JDBC node.
A REST connection is the exception to auto-fill, because it exists to be shared by many nodes that each send a different request: put the vendor's headers and token on the connection once, and rotating a key is a single edit. Headers are merged per key at run time, and the node wins on a key it sets itself; the node's own url and request body are never overwritten. A node with no URL of its own inherits the connection's.
The Copy SQL / Export SQL output is display-only and never executed. Secret values (passwords, tokens, keys, connection strings) are replaced with named placeholders such as ${DUCKLE_PASSWORD}, so the exported script stays valid and is safe to share - substitute the real value at run time. To emit the real credentials instead (so the script runs unchanged), set the environment variable DUCKLE_EXPORT_INCLUDE_SECRETS=1; the output then contains live secrets and should be handled accordingly.
Bind any field to a context variable that resolves at run time. Useful for dev vs prod, per-environment paths, secrets injected from CI, etc.
In a context file (workspace/contexts/prod.context.json):
{
"name": "prod",
"vars": {
"DB_HOST": "db.internal.acme.com",
"S3_BUCKET": "acme-prod-data",
"BATCH_SIZE": "10000"
}
}
In the Properties panel of any node, switch a field from Manual to Context and pick DB_HOST. Or inline-reference one with ${DB_HOST} in a string field.
Pick the active context from the topbar's Context dropdown. Switch contexts and re-run without editing the pipeline.
Prerequisites
cargo-tauri CLI: cargo install tauri-cli --version "^2"Clone and install
git clone https://github.com/slothflowlabs/duckle
cd duckle
npm --prefix frontend install
Run in development (hot-reloading frontend plus the native shell):
cargo tauri dev
Build a release binary:
# The --features custom-protocol flag is required: without it, tauri-codegen
# embeds the dev URL instead of the bundled frontend.
cargo build --release --manifest-path apps/desktop/Cargo.toml --features custom-protocol
Outputs land in target/release/duckle (or duckle.exe). The engine is not statically linked: DuckDB downloads at first launch, which is why the build is fast and the binary is tiny.
Run the tests:
cargo test # workspace unit + plan tests
DUCKLE_DUCKDB_BIN=/path/to/duckdb cargo test -p duckle-duckdb-engine # full integration suite
duckle/
apps/desktop/ Tauri 2 shell: Tauri commands, engine installer, llama runtime, window
frontend/ React 19 + Vite + TypeScript: the designer UI + chat panel
crates/
duckdb-engine/ Compiles the node graph to SQL and drives the DuckDB CLI
slothdb-engine/ SlothDB adapter
scheduler/ Cron / interval / file-watch triggers
metadata/ Schema and type model
plugin-sdk/ Connector / inspector traits
connectors/ Source and sink connectors
runtime, workflow-engine, transform-engine, stream-engine, execution-core
COPY ... TO statements; cancel kills the process. No statically linked database, so the binary stays small.llama-server subprocess on 127.0.0.1 exposing an OpenAI-compatible chat-completions API. The chat panel streams from it via SSE. The model is sandboxed: no fs, no net, no tools - it can only emit text.A few knobs you can set without touching code.
| Setting | Where | Effect |
|---|---|---|
| Theme | Topbar sun/moon toggle | Light / dark, persisted to localStorage |
| Workspace | Topbar workspace pill -> Switch | Change the folder Duckle reads/writes to |
| Active engine | Topbar engine selector | DuckDB (default) or SlothDB - per-pipeline |
| Active context | Topbar context dropdown | Switches which context variables resolve at run time |
| AI Assistant baseURL | xf.ai.llm / xf.ai.embed / xf.ai.classify props | Point at any OpenAI-compatible endpoint (default: Duckie's local llama-server) |
| Per-stage retry | Properties panel -> Advanced tab | Total attempts + linear-scaled backoff per stage |
| Per-stage memory cap | Properties panel -> Advanced tab | PRAGMA memory_limit applied just to that stage |
| Per-stage materialize | Properties panel -> Basic tab | auto, view (lazy), memory (read once, table in RAM), or disk (read once, streamed via a temp Parquet file for huge intermediates) |
| DuckDB extensions | Pre-fetched at install; lazy-loaded for spatial | See First-launch extension pre-fetch |
Env var RUST_LOG | Before launching the binary | RUST_LOG=debug duckle.exe to see verbose engine logs |
Env var DUCKLE_DUCKDB_BIN | Before running engine tests | Points the integration test suite at a DuckDB CLI |
Env var DUCKLE_CA_CERT | Before launching the binary | Path to a PEM bundle of extra CA certificates to trust (corporate proxy / private CA), added on top of the OS trust store and bundled roots |
Env var DUCKLE_HTTPS_PROXY (or standard HTTPS_PROXY / HTTP_PROXY / ALL_PROXY) | Before launching the binary | Routes REST / cloud-API connectors and the in-app updater through an HTTP proxy, e.g. http://user:pass@proxy:8080. Use the standard vars to also cover engine / model downloads |
A few patterns that consistently produce sub-second runs at small / medium data scale, and tractable runs at warehouse scale.
| Tip | Why |
|---|---|
| Use Parquet, not CSV, for intermediate steps | Columnar + compressed; DuckDB reads only the columns the next stage needs. CSV is fine for source / sink at the edges. |
| Push filters as early as possible | xf.filter early in the graph compiles to a WHERE that runs at scan time, not a post-scan filter. |
Use the vss + fts indexes | Vector + full-text search hit DuckDB extensions directly. Faster than the alternative of pulling data out and indexing in Python. |
| Avoid per-row API calls when batch APIs exist | xf.ai.embed batches up to 100 inputs per request; snk.rest defaults to one batched request. Per-row patterns (xf.ai.llm, snk.webhook) are slower by design - use them when you actually need per-row behavior. |
| Cap heavy aggregates with the per-stage memory limit | Properties panel -> Advanced -> Memory limit (MB) prevents one big GROUP BY from blowing through all of RAM. |
Use ctl.checkpoint for long-running pipelines | A checkpoint stage writes a Parquet snapshot to a path you choose, so a future run can resume from there with src.parquet. |
Disable xf.debug.log in prod | Logging rows is per-row I/O; fine for dev, costly at scale. |
| Sort once at the end, not in the middle | xf.sort is a global sort; doing it once before the sink avoids re-sorting downstream. |
Put an xf.dbt node behind its upstream, not first | When a dbt node has upstream stages, Duckle warms dbt's project parse in the background while those stages run, so dbt run reuses a warm cache instead of paying a cold parse. Set DUCKLE_DBT_PREWARM=0 to disable. |
Yes, free + open source. Dual-licensed MIT OR Apache-2.0. You can use it commercially, fork it, sell what you build with it. No usage limits, no telemetry.
It covers similar ground - moving data across 190 sources and destinations - but locally, with nothing to host and no per-row, per-connector, or per-seat billing. Pipelines are built visually or from plain English and compile to readable DuckDB SQL that runs wherever you deploy it: a laptop, a server, CI or a container. The trade-off is scope: Duckle does not split one query across a cluster, so for warehouse-scale replication you push the work down into the source system or point the output at the system that scales.
Yes. Duckle executes on the embedded DuckDB engine, so there is no vendor warehouse to buy, no vendor platform to sign up to, and no account. You run it where you choose: a server or VM you own, a container in your own AWS, Azure or GCP account, or a workstation. It needs no outbound network of its own, which suits air-gapped, on-premise and compliance-sensitive work. Pipelines still read from and write to cloud systems whenever you point them at one.
Airbyte focuses on hosted extract-and-load connectors; dbt focuses on SQL transformation; Talend is a heavyweight GUI suite (its free Open Studio edition was discontinued in early 2026). Duckle is a single open engine that does extract, transform, and load together - write it in Python, wire it from connectors, or draw it on a canvas - compiles to DuckDB SQL, and can also run dbt on DuckDB inside the same tool. One format, one engine, running on your own infrastructure rather than a vendor's, with no per-row billing.
No. Duckle makes no outbound calls of its own from wherever you run it, laptop or server. The engines (DuckDB, llama.cpp) are downloaded from official upstream releases on first launch and then run in place. The only network calls Duckle makes on your behalf are the ones your pipelines explicitly do (e.g. a src.s3 reading from your S3 bucket, or xf.ai.embed if you configure it to hit OpenAI).
Duckie needs no network once its model is downloaded - and if you would rather it did not run in-process at all, point it at your own OpenAI-compatible endpoint.
Bigger than people assume, because the ceiling is the instance you provision rather than the laptop you develop on. The engine is parallel and uses every core available, so the same pipeline that you debug against a sample on a laptop runs against the full set on a large server without changing. For reference, 96M rows come out of live Postgres to Parquet in 39.9s.
Past whatever instance you are willing to pay for, you have two routes that do not involve rewriting anything: turn on pushdown so the query executes inside the source database, or point the output at a warehouse or lakehouse that scales horizontally. What Duckle will not do is spread a single query across a cluster.
No - Duckle downloads it for you on first launch. The download is ~30 MB and includes the most-used extensions (httpfs, postgres, mysql, iceberg, delta, vss, fts, etc.) so the first time you touch a Postgres source there's no mid-pipeline network pause.
73 to 110 MB, depending on platform. As of v0.7.0: macOS 73 (x64) to 88 (arm64), Linux 74 (arm64) to 100 (x64), Windows 98 (arm64) to 110 (x64). It embeds the headless runner and the MCP server, and the headless runner on its own is 27 MB. The engines aren't statically linked - DuckDB (~50 MB with extensions) and the Duckie LLM (~1.1 GB for the Qwen GGUF) both download on first launch with a guided installer into your app-data folder, so they update independently of the app.
Yes. The AI transforms (xf.ai.embed, xf.ai.llm, xf.ai.classify) accept a baseUrl prop. Point it at any OpenAI-compatible /v1/... endpoint and an apiKey and Duckle uses that instead. The local Duckie chat panel is hardwired to localhost; the pipeline AI transforms are configurable.
In the workspace folder you pick on first launch (see Workspace and Git flow). Pipelines are plain JSON files you can commit to Git, diff, branch, and review.
Via Git, yes - check the workspace into a repo and use standard branch/PR flows, and deploy the result to a shared server where the console has roles and an audit log. What there is not is a real-time multiplayer canvas: two people editing the same pipeline at the same moment is a merge, not a live session.
Yes. Build Pipeline (right-click a pipeline) produces a single self-contained executable that runs anywhere with nothing installed - drop it on a server or CI runner and execute it, or schedule it with cron / systemd / Task Scheduler. The embedded duckle-runner can also run a workspace pipeline JSON directly (duckle-runner --pipeline pipeline.json). See Server deployment. You can also import the engine crate (duckle-duckdb-engine) into your own Rust binary.
For 90% of common pipelines (read source -> simple transforms -> sink), yes - the Qwen 2.5 Coder model is tuned for structured-JSON generation. For long, complex pipelines you'll likely want to iterate: describe the first half, click insert, then ask for the next half. You can also swap the model: point xf.ai.llm's baseUrl at GPT-4 or Claude for more capable pipeline drafting.
No. Once llama-server and the Qwen GGUF are downloaded into your app-data directory, Duckie needs no network at all. Nor does it have to run in-process: point it at your own OpenAI-compatible endpoint and it uses that instead. Tested by killing wifi and asking it for a pipeline - works fine.
DuckDB's SQL surface is wide enough to express most ETL work, it's vectorized and fast on a laptop, it has first-class Iceberg/Delta/Parquet readers, and its extension model lets us add vector + full-text + Postgres ATTACH without code changes. Polars is great but doesn't ship the cloud/format/extension breadth we need; Spark is a great cluster but overkill for the local-first niche we're in.
See the Contributing section and crates/duckdb-engine/src/plan.rs (planner branch) + crates/duckdb-engine/src/lib.rs (executor). The shortest path: copy an existing connector with similar shape (e.g. src.rabbit for a streaming source, src.dynamodb for an HTTP+auth API), adapt, add a test, flip the palette tile.
| Symptom | Likely cause | Fix |
|---|---|---|
| Window opens but content shows "localhost refused to connect" | Release binary built without --features custom-protocol (the v0.0.7 bug) | Rebuild with cargo build --release --features custom-protocol per Build from source. The release workflow already passes this flag. |
| "DuckDB CLI not found" on Run | First-launch installer was skipped or interrupted | Open the engine setup modal from the toolbar; click Install on DuckDB |
| "Couldn't download Duckie AI Assistant (HTTP 404)" | Pinned llama.cpp build temporarily unavailable from upstream | Bump LLAMACPP_BUILD in apps/desktop/src/engine_manager.rs to a recent stable, rebuild |
| Linux: app won't launch, missing libwebkit | WebKitGTK 4.1 isn't installed | sudo apt install libwebkit2gtk-4.1-0 (Debian/Ubuntu) or your distro's equivalent |
| macOS: "App can't be opened because Apple cannot check it" | Gatekeeper, unsigned binary | Right-click the binary -> Open -> Open Anyway |
| Pipeline runs but a connector errors with "extension not loaded" | Lazy-loaded extension (e.g. spatial) downloaded mid-run and failed | Run duckdb :memory: -c "INSTALL spatial; LOAD spatial;" from a terminal to pre-install; relaunch Duckle |
| Chat panel says "AI engine not registered" | Old version of Duckle before AI shipped (pre-v0.0.10) | Update to latest release |
| Duckie generates a pipeline but Insert doesn't put anything on the canvas | Active pipeline tab has been closed; nothing to insert into | Open a pipeline (or create a new one) before clicking Insert |
| MotherDuck / Snowflake auth fails | Token expired, or PAT lacks the role you're trying to use | Regenerate in the vendor UI; paste into the Connection in Duckle |
Postgres ATTACH says "could not connect" | Local SSL mode mismatch | Connection -> Advanced -> set SSL mode to disable for localhost / require for production |
| AI tests skip with no failure | DUCKLE_DUCKDB_BIN isn't set | export DUCKLE_DUCKDB_BIN=/path/to/duckdb before cargo test |
| TLS "UnknownIssuer" / "invalid peer certificate" behind a corporate proxy | A TLS-inspecting proxy (Zscaler, Netskope, ...) re-signs traffic with its own CA | Duckle trusts your OS certificate store on top of its bundled roots, so the proxy CA in the Windows / macOS / Linux store is honoured automatically. If the CA isn't in the store, point DUCKLE_CA_CERT at a PEM file containing it. Note: DuckDB's own extension fetch (extensions.duckdb.org) and cloud reads (S3 / GCS / Azure) run inside the DuckDB engine with its own TLS, so also allow / exempt extensions.duckdb.org from inspection. |
| REST / cloud calls fail with "Connection Failed" / timeout (os error 10060) behind a proxy | The network requires an HTTP proxy to reach the internet, and Duckle is connecting directly | Set HTTPS_PROXY (and HTTP_PROXY) to your proxy URL, e.g. http://user:pass@proxy:8080, before launching Duckle - REST / cloud connectors and the updater now route through it. Use DUCKLE_HTTPS_PROXY if you want a Duckle-only proxy without changing global env. |
If you see something not listed, please open an issue with steps to reproduce + the relevant log line.
Duckle's CI pipeline runs on both GitHub and GitLab - the project mirrors to both. Push / pull-request / merge-request / tag events all trigger builds.
| Trigger | GitHub Actions | GitLab CI |
|---|---|---|
| Push to main or feature branch | .github/workflows/ci.yml | .gitlab-ci.yml (test + desktop-build stages) |
| Pull request / merge request | .github/workflows/ci.yml | .gitlab-ci.yml (same stages, rules: gate on MR events) |
Tag v* | .github/workflows/release.yml | .gitlab-ci.yml (release stage; uploads binaries to GitLab Releases) |
What each pipeline does:
npm ci + npm run build (type-check + bundle)cargo test --workspace on Linux + macOS + Windowscargo build --release --features custom-protocol then grep the binary for the embedded frontend JS chunk (catches the v0.0.7-class "binary loads devUrl" bug at PR time)See .github/workflows/ and .gitlab-ci.yml for the exact steps. The two pipelines are kept feature-equivalent so contributors can fork to either platform.
Nothing regenerates this README, the hero / flow SVGs, or the download links automatically - they are hand-maintained, so they drift unless each release updates them. Treat the README as a release artifact: walk this checklist every time before tagging.
# 0. Update the README in the SAME commit as the version bump:
# - bump every vX.Y.Z reference (the Download / Install link, badges)
# - refresh capability tables for any new sources/transforms/sinks
# - add/replace screenshots in docs/assets for shipped features
# - re-check the hero/flow SVG wording if positioning changed
# 1. Bump version in apps/desktop/tauri.conf.json
# 2. Commit (README + version together)
git commit -am "Release: bump to vX.Y.Z"
# 3. Tag + push
git tag vX.Y.Z
git push origin main vX.Y.Z
# Both GitHub Actions and GitLab CI pick up the tag and build the
# release artifacts automatically. Once green, the draft release on
# GitHub gets the binaries uploaded; un-draft + mark Latest with:
gh release edit vX.Y.Z --draft=false --latest
A server somebody can set up in a browser, an ordered plan of pipelines, a catalog of the whole workspace, and a run that stopped reading the source three times to answer one question.
/healthz means an orchestrator can tell a starting server from a wedged one, and a schedule that stops working now says so instead of failing quietly.plans.json, and can be scheduled like a single pipeline. A plan whose pipelines failed no longer reports that it worked.SELECT COUNT(*), and since nodes are views, each one re-ran the whole chain. A source to filter to sink pipeline read a 96M-row Postgres table three times to do one pass of work. Each relation is now counted once, and a sink takes its count from the Parquet footer of the file it just wrote, which is a metadata read: 0.06s against 16.7s for the equivalent count over the source. Measured on that pipeline, baseline against this release, interleaved on one machine: 56.3s to 18.8s, and 288,159,946 tuples scanned down to 96,011,803. That puts it level with a hand-written DuckDB COPY doing the same work, at 1.02x. A remote XML stream over SFTP was reading 8 KiB per round trip and now reads 256 KiB: 75 MB and 700,000 rows went from 17.0s to 10.0s.Talend jobs import straight into the canvas, and credentials are masked more carefully in exported SQL.
.item job, translates it, and opens it as a new pipeline tab, laid out on the canvas at the coordinates the job was drawn with. Measured on a real 44-job corpus: all 44 parse and 211 of 216 nodes map, the only refusal being a site-specific custom component. Nothing is written to your workspace until you save, so a job that translates badly costs a closed tab.${ENV:...} placeholders instead of guesses. Connections stored outside the job file are named, so you can fill them in or point the node at a saved connection. tMap outputs computed by Java are listed column by column with the expression to rewrite as SQL. A component with no Duckle equivalent is imported as a labelled placeholder, so the shape of the job survives rather than quietly losing a step.duckle-runner import <dir> walks the tree, converts every job it finds, and mirrors the folder layout under --out so two jobs that share a name cannot overwrite each other. Measured on a real 125-file corpus: 42 files hold a job and 83 do not (routines, contexts and SQL templates share the extension), all 42 convert with none failing, and exactly one component across the whole corpus has no equivalent - a site-specific custom one. Everything else still to resolve is credentials that were never in the job files to begin with: 119 encrypted passwords and 75 connections defined outside the job. The closing tally lists unmapped components by how often they appear, which is both the answer to "is this migration viable" and the shortest path to finishing it. --json for a script, --strict to fail a CI job.prod rewrote production_report.parquet as ${DUCKLE_PASSWORD}uction_report.parquet. The secret itself was always protected; the damage was to everything else, and it mattered most when reading the Plan or SQL view to debug. Matching is now delimiter-aware, so LOAD postgres is left intact while a one-character password is still masked in password=p'. Deliberately no minimum length: a short password is still a password.A multimodal AI data store, an importer for legacy visual ETL jobs, a chat model you choose, and two geometry transforms that finally have the second input they always needed.
src.pixeltable reads a table, optionally filtered by a Pixeltable expression, a column subset and a limit; snk.pixeltable inserts into an existing table or creates one from the incoming rows. Versioned reads work by passing myapp.media:3. The exchange runs over Parquet on both legs - Pixeltable exports, Duckle ingests with read_parquet, and on the way back Duckle writes Parquet that Table.insert takes directly - so no rows are serialised one at a time. Pixeltable is a Python library, so the desktop app provisions a private Python for it with uv on first use; nothing is installed into your own environment.ST_Union_Agg before the operation, attributes of the input layer are preserved, and features left with nothing are dropped. Thanks to @OmarMustaafa for reporting it twice with screenshots. A test now pins this contract for every component whose builder needs a second input, checked by removing a port and confirming it fails.${ENV:...} placeholders rather than guesses, connections that live outside the job file are reported rather than silently half-imported, and anything with no equivalent is imported as a labelled placeholder so the shape of the job survives instead of quietly losing a step.duckle-runner is now published as a release asset, and there are ready workflows for GitHub Actions and GitLab CI under docs/ci/. They gate every push on duckle-runner validate, which compiles pipelines to SQL without opening a source, writing a sink, or needing credentials or a network. This is the check that catches a column renamed in one commit and still referenced by another - they merge cleanly, and nothing else notices.Full notes: see the v0.6.0 release.
Power mode, context layering, and an Oracle extract that is now faster than python-oracledb with pyarrow on the same table.
Power mode (Settings -> Power mode). Two throughput settings per workspace. Pipelines at once caps how many run together; the placeholder shows the machine's core count. Spill folder points DuckDB's spill files at a bigger or faster disk. Only the lever with a measurement behind it is offered: independent pipelines scaled about 3.8x across 8 concurrent processes on a 20-core box, while splitting a single pipeline across processes measured slower (72ms to 123ms at 8-way), so there is deliberately no option for it. Each concurrent run gets its own memory limit and its own DuckDB process, so N at once needs roughly N times the memory, and the panel says so.
Scheduled runs have a ceiling. Every schedule that came due in the same tick fired at once, so ten due at midnight meant ten pipelines each sized for the whole machine. They are now bounded, by power mode where it is set and by a sane default otherwise. The headless duckle serve honours the same setting, so desktop and server agree.
Contexts can be layered (#204). A context can declare a Layer; higher layers override lower ones. A shared base plus a per-environment override is now expressible directly: give the base layer 0 and the environment a higher number, and the override applies quietly. Previously all contexts merged flat in repo order, so every intended override looked like a collision and had to be resolved by hand. Only two contexts on the same layer defining the same name are still reported, because nothing there says which should win. Workspaces that set no layers merge exactly as before.
Oracle extracts beat python-oracledb (#221). Three changes, each measured on a 1,466,723-row x 236-column table with the same query and SNAPPY on both sides:
NUMBER columns are now measured before the write instead of typed after it. Those columns have no declared width, so they used to travel as text and be typed by a pass over the finished Parquet - which is exactly the pass a direct write skips, meaning one such column forced a whole second pass over every column. The ambiguous columns are now read on their own first (about 2s for 4 of 236), their real widths pin the schema, and the file is written once. Both reads share one snapshot via a read-only transaction, so they cannot disagree.ResultSet<Row> reconstructs every value in the row; on this table that was 346 million reconstructions, measured at 11.5s of a 42.7s fetch against a 31.2s floor.NUMBER values no longer allocate a string per cell while being rescaled - 88 million allocations per run on this shape.Together: 100.7s to 65.0s in the shape reported on #221, against 68.6s for python-oracledb with pyarrow doing the same job on the same machine. With column types already pinned it is about 56.7s. Output was verified against pyarrow's: identical row counts, and equal sums, hashes, ranges and null counts across every column type. Worth noting that python-oracledb maps an unconstrained NUMBER to DOUBLE, which cannot hold the 24 significant digits one test column carries; Duckle types it exactly, so the comparison is not quite like for like and not in our favour.
A direct Parquet write no longer produces string columns (#221). With Write directly from the source enabled on a table containing any bare NUMBER, those columns were written as text while the run reported success. The source now declines the shortcut when a column cannot be typed before the write, and says so in the run log. Anyone who enabled that toggle on v0.5.9 against such a table should re-check the output.
Concurrent runs no longer fight over spill files. DuckDB's default spill location is already per-run, but setting a shared spill folder made every run share one - which reads as a flaky run rather than a bug: across three trials of four concurrent spilling queries, a shared folder lost 3 of 12 runs to a segfault or a delete failure, private folders lost 0 of 12. Each run now spills into its own subfolder.
Full notes: see the v0.5.10 release.
A complete planned-component breakdown lives in docs/roadmap.md. Highlights:
protoc at build time)orc-rust and our workspace pin)russh + russh-sftp on the ring backend, password / key auth, host-fingerprint pin)Contributions, issues, and ideas are welcome. Duckle is young and there is a lot of green field. Open an issue to discuss a change before a large PR, match the existing code style, and keep changes focused. Run cargo test and npm --prefix frontend run build before submitting. See CONTRIBUTING.md.
Thanks goes to these wonderful people who contribute to Duckle (emoji key):
mits 🚇 ⚠️ | Christian 🤔 ⚠️ 💻 | gmacc00 🚇 ⚠️ 💻 | Stéphane Heckel 🚇 ⚠️ 💻 | Steven Snowball 🚇 ⚠️ 💻 | Suffian0610 🚇 ⚠️ 💻 | add944 🚇 ⚠️ 💻 |
KNP-BI 🚇 ⚠️ 💻 | Richard Wesley 🚇 ⚠️ 💻 | micha9ski 🚇 ⚠️ 💻 |
This project follows the all-contributors specification. Contributions of any kind - code, docs, design, bug reports, ideas - are welcome and recognized here. Comment on any issue or PR with @all-contributors please add @name for code, doc and the bot opens a PR adding them.
Licensed under either of MIT or Apache-2.0 at your option.
Rust
63.6%
TypeScript
23.4%
HTML
4.7%
CSS
3.6%
Python
2.5%
JavaScript
1.6%