unwind
A DuckDB-only SQL/Python orchestrator with Jinja templating, a semantic layer, and lineage at the table, column, and value level. An alternative to Dbt and SQLMesh, focused on deterministic value-lineage backed by a SQLGlot AST and DuckDB as the single execution engine.
A project is a directory of .sql and .py files. SQL models give full
table/column/value lineage via the SQLGlot AST. Python models are opaque to
lineage but participate in the DAG like any other node — typical use cases
are sources (you bring your own Postgres/Oracle/S3/CSV ingestion code)
and sinks (exporting a final table elsewhere). The two kinds compose
freely.
Unwind deliberately ships zero DB-third-party dependencies. If you want to
pull from Postgres, you pip install psycopg yourself; if you want Oracle,
you pip install oracledb. Unwind's only DB is DuckDB.
Status: alpha — table, column, and deterministic value lineage all work end-to-end, plus a web UI and an optional LLM investigator (pydantic-ai, multi-provider).
Install
The core install (duckdb, jinja2, sqlglot) is enough to load a project,
plan its DAG, run it on DuckDB, and compute table / column / value lineage in
Python. The two surfaces that reach beyond the core — the web UI and the LLM
investigator — live behind optional extras.
| Extra | What it enables | Pulls in |
|---|---|---|
web |
Project.show() (FastAPI/Uvicorn DAG explorer) |
fastapi, uvicorn[standard] |
llm |
Project.get_investigator() (multi-provider via pydantic-ai) |
pydantic-ai |
all |
Both of the above, no decisions required | sum of the two extras |
Pick what you need:
pip install unwind-sql # core only
pip install "unwind-sql[web]" # add the web UI
pip install "unwind-sql[web,llm]" # web UI + LLM investigator
pip install "unwind-sql[all]" # everything — recommended for trying it out
For local development on this repo:
uv sync # core + the dev group (which includes web/llm bits and pyarrow)
Run the example
The bundled example computes net margin per order over 5 raw/ref tables, with a Jinja macro and a final aggregation by warehouse:
cd example/
uv run python generate_data.py # one-time: write the 5 parquet sources
uv run python main.py
The script:
- Loads
models/(mixed.sql+.py) and runs the full DAG on DuckDB. - Prints the table & column lineage of
int_tax_costs.local_tax_fee. - Traces
local_tax_feefororder_id="ORD-7892"back to the raw values that contributed (raw_orders.gross_sales = 500.0,ref_local_taxes.tax_pct = 0.20, …). - If
OPENAI_API_KEYis set, asks an LLM to explain the trace in plain language and flag suspicious values (usespydantic-ai, swap providers by passingllm_provider="anthropic"etc.). - Opens a browser tab on the web UI (React Flow /
@xyflow/reactDAG- per-column lineage tree, dagre layout). Press
Ctrl+Cto stop.
- per-column lineage tree, dagre layout). Press
raw_orders in the example is a Python model (example/models/raw_orders.py)
that reads the bundled parquet fixture via pyarrow. To wire it to your own
source (Postgres, Oracle, S3, REST API, …) edit example/models/helpers.py —
Unwind has no built-in connectors, so you import the lib you want and call it
yourself.
See example/main.py and example/README.md for the model walkthrough.
Python models
A file in models/ whose Python module defines a top-level callable
model(context) is recognised as a node of the DAG. Anything else in
models/*.py is imported as a plain helper module — so from helpers import load_data just works.
# models/raw_orders.py — Arrow-native ingestion, zero-copy into DuckDB
import pyarrow.parquet as pq
GROUP = "costs"
MATERIALIZED = "view" # "table" (default) or "view"
DEPENDS_ON = () # tuple of upstream model names
def model(context):
# context.connection (live DuckDBPyConnection),
# context.variables (Jinja vars), context.project_root (path passed to load()).
return pq.read_table("data/raw_orders.parquet")
The return value is registered into DuckDB (zero-copy for Arrow tables and
DuckDB relations). Returning a str runs it as SQL; returning None means
the function did its own work via context.connection.
Want to ingest from Postgres? Install psycopg yourself and let DuckDB pull
it natively:
def model(context):
context.connection.execute("INSTALL postgres; LOAD postgres;")
context.connection.execute(
"CREATE OR REPLACE TABLE raw_orders AS "
"SELECT * FROM postgres_scan('host=… dbname=…', 'public', 'orders')"
)
Loading SQL definitions from somewhere else
Models usually live in .sql files, but you can also feed them in directly
from any source you can fetch yourself — a metadata table, a YAML registry,
an HTTP endpoint. Unwind doesn't connect to anything; you bring the rows.
import unwind
rows = [
{"name": "stg_users", "sql": "SELECT id, email FROM raw_users", "kind": "model"},
{"name": "plus_one", "sql": "{% macro plus_one(c) %}{{c}}+1{% endmacro %}", "kind": "macro"},
]
project = unwind.load_from_rows(rows, origin="catalog.sql_defs")
Reusing an existing DuckDB connection
Project.run() opens a fresh in-memory DuckDB by default, but you can pass
your own connection — useful when you've already installed extensions,
attached external databases, or configured secrets:
import duckdb, unwind
conn = duckdb.connect(":memory:")
conn.execute("INSTALL httpfs; LOAD httpfs;")
unwind.load("models/").run(connection=conn)
conn.execute("SELECT * FROM fct_warehouse_profitability").fetchall() # still open
Test
uv run pytest
uv run ruff check
uv run ty check
Metadata
Release files for unwind-sql 0.8.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| unwind_sql-0.8.0.tar.gz | 655.4 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| unwind_sql-0.8.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 1.3 MB
Release files / unwind_sql-0.8.0.tar.gz
| Download URL | unwind_sql-0.8.0.tar.gz |
|---|---|
| Size | 655.4 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
674363de806abed5cb9b8f09a003e208f4c86f154df963eeeaa340658cb9cfb0
|
|
BLAKE2b-256 checksum How to use checksums |
8f678fdb155ff86da569a6a7b34beb7de4007856fe0fc7c492d51d4f88b10454
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
uv/0.11.16 {"installer":{"name":"uv","version":"0.11.16","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}
|
Release files / unwind_sql-0.8.0-py3-none-any.whl
| Download URL | unwind_sql-0.8.0-py3-none-any.whl |
|---|---|
| Size | 673.5 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
cc4bd1b7940406843f478664af862dfe1728331e550f930137d050e114182331
|
|
BLAKE2b-256 checksum How to use checksums |
e173386b31446113caf92c9c99931d3622b42a1ca06c22043f08a1cc3100736f
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
uv/0.11.16 {"installer":{"name":"uv","version":"0.11.16","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}
|