What havn Supports
This page lists, feature by feature, what havn does today and what it does not.
It is updated with each release, so if something here looks out of date against
the version you are running, it is a bug in this page. Missing something you
need, or found a gap we have not written down? Open an issue at
github.com/chraltro/havn/issues.
Status words mean exactly this:
| Word |
Meaning |
| Supported |
Works today, covered by tests, safe to build on. |
| Partial |
Works with a stated limitation. Read the note before you rely on it. |
| Not supported |
Does not work and is not being built right now. The note says why. |
| Planned |
Designed and queued, but not in a release yet. |
SQL features
havn hands your SQL to DuckDB unchanged, so anything DuckDB can run, havn can
build. What varies is how well havn's own analysis (dependency extraction for
the DAG, and column-level lineage) understands the construct. Dependency
extraction is what decides build order, and it handles every construct below.
| Feature |
Status |
Notes |
| CTEs |
Supported |
Build order and column lineage both correct through CTE chains, including recursive CTEs. |
| Window functions |
Supported |
Including QUALIFY. Column lineage traces PARTITION BY and ORDER BY columns into the window's output column. |
UNION / UNION ALL |
Supported |
Every branch is traced, including UNION ALL BY NAME. |
SELECT * over a join |
Supported |
A column name present on both sides stays two output columns, named the way DuckDB names them when the model is built. Star expansion needs a column catalog, a connection or a bind-pass schema. |
SELECT * EXCLUDE (...) |
Supported |
Excluded columns are gone from the lineage output too. |
SELECT * REPLACE (...) |
Supported |
The replacement expression is the source of the replaced column. |
Nested subquery in FROM |
Supported |
The subquery alias is not reported as a source table. |
| Struct and list columns |
Partial |
Builds correctly, and unnest lineage is exact. s.field and s['field'] both resolve to the struct column s; lineage does not point inside the struct. |
PIVOT |
Partial |
Builds correctly and the DAG sees the source table. The grouping columns are exact; each pivoted output column maps to the ON and USING columns together. Enumerating a pivot's output columns needs a live connection. |
UNPIVOT |
Supported |
The NAME and VALUE outputs map to every unpivoted column. |
QUALIFY |
Supported |
Builds correctly, column lineage correct. |
ASOF JOIN |
Supported |
Builds correctly, dependencies detected, column lineage exact. |
LATERAL |
Partial |
Over-broad, never wrong: sqlglot gives a LATERAL body no scope of its own, so the correlation predicate's columns come along with the projection. |
COLUMNS('regex') |
Supported |
Needs a live connection to enumerate the matched columns. |
GROUP BY ALL |
Supported |
|
Python macros (@macro) |
Supported |
Registered as DuckDB scalar UDFs, callable anywhere in a model. |
Table macros (@table_macro) |
Supported |
Called with FROM. Output columns come from the declared schema=. |
CREATE MACRO SQL macros |
Supported |
.sql files in macros/ are registered alongside the Python ones. |
| Jinja templating |
Not supported |
Deliberate. Models are plain SQL; reusable logic goes in a macro instead. |
Materializations
Set with @config materialized=... at the top of a model.
| Materialization |
Status |
Notes |
view |
Supported |
|
table |
Supported |
Rebuilt when the model's content hash or any upstream hash changes. |
incremental, delete+insert |
Supported |
Default strategy. Needs unique_key. |
incremental, merge |
Supported |
Needs unique_key. Updates every matched row rather than only changed ones. |
incremental, append |
Supported |
No unique_key needed and no dedup. |
on_schema_change policy |
Supported |
append_new_columns (default), ignore, fail, sync_all_columns, set with @config on_schema_change=. Staging and target are compared on name and type in both directions before any write, so a policy that refuses the change leaves the target untouched. No option backfills old rows for a newly added column, and only top-level columns are tracked: a field inside a STRUCT, MAP or LIST is invisible to the comparison. |
ephemeral |
Supported |
Inlined into every consumer as a __havn_-prefixed CTE rather than materialized; chains and the model's own CTEs are hoisted in dependency order, and aliases are preserved. Reported as inlined. @assert, @grain, {this}, @watermark and incremental_filter are rejected on an ephemeral model, because each of them needs a target table that never exists. |
snapshot (SCD2 history) |
Supported |
@config materialized=snapshot, unique_key=... with strategy=check (optionally check_cols=) or strategy=timestamp, updated_at=, and hard_deletes=ignore|invalidate|new_record. Writes valid_from, valid_to, is_current, row_hash (plus is_deleted under new_record), renameable project-wide under snapshots.meta_columns. Replays are no-ops; a duplicate unique_key in the query is refused before any write; --force re-runs the merge and never drops history, so dropping the table is the reset. New source columns are appended with NULL history, removed or retyped columns are an error, and incremental_filter / @watermark are rejected. Not to be confused with havn snapshot / havn rewind, which save whole-warehouse restore points. |
incremental, microbatch |
Supported |
@config incremental_strategy=microbatch, event_time=, batch_size=hour\|day\|month\|year, begin=, lookback= (dbt's names). The model filters itself on the {start} and {end} placeholders; havn adds no WHERE clause of its own. Each window is a DELETE plus INSERT in its own transaction, recorded in _havn.batch_state, so a failed backfill resumes at the window that failed. Boundaries are UTC and naive. havn transform --event-time-start/--event-time-end backfills an explicit range; --force reprocesses from begin. incremental_filter and @watermark are rejected with it, and more than 100,000 windows is refused rather than attempted. No parallel batches: windows run in order, one at a time. |
Validation
havn validate checks project structure, config and the DAG before you build.
| Check |
Status |
Notes |
| DAG construction, cycle detection |
Supported |
Circular dependencies are reported by name. |
| Table and column name checking |
Supported |
The bind pass resolves every model against a shadow catalog, so columns on upstreams that have never been built are checked too. --no-bind falls back to the name-level check, which skips unbuilt upstreams. |
| Type resolution and bind errors |
Supported |
havn validate hands each model's SQL to the DuckDB binder against a shadow catalog. Resolves output types and catches wrong arity, unknown functions, operator overload failures, missing columns, missing struct keys, ambiguous references, aggregation without GROUP BY and set-operation arity mismatches, without reading a row. Caveat: it does not catch value conversions. CAST(some_varchar AS INTEGER) binds clean and fails at run time on the first row that is not a number, as does comparing a numeric column to a string constant. A clean bind is not a guarantee that the build will succeed. See Data Quality. |
| Column-level lineage |
Partial |
See the per-construct table below. |
| Contract column declarations |
Supported |
A contract's columns: block is checked against the schema the bind pass infers, so a break is reported before the build rather than after it. See Testing. |
| Schema drift without a contract |
Partial |
Off by default behind validation.schema_drift: warn and --schema-drift. See Testing. |
Unknown @config keys |
Partial |
Unrecognised keys are ignored rather than rejected, so a typo such as materialised=table silently builds a view. |
Column lineage, per construct
Column lineage is what powers the lineage view and impact analysis. Every row
below is pinned by tests/test_lineage_conformance.py, which checks the output
column set against DuckDB's own DESCRIBE of the built model and the per-column
source mapping against a written-out expectation. See
Lineage for the detail.
| Construct |
Status |
Notes |
| CTE chain, recursive CTE |
Supported |
|
Nested subquery in FROM |
Supported |
|
Window function, QUALIFY |
Supported |
|
UNION ALL, UNION ALL BY NAME |
Supported |
Every branch. |
SELECT * over a join |
Supported |
Both sides of a duplicated name survive. |
SELECT * EXCLUDE, SELECT * REPLACE |
Supported |
|
unnest, UNPIVOT, ASOF JOIN, GROUP BY ALL |
Supported |
|
| Correlated subquery |
Supported |
|
COLUMNS('regex') |
Supported |
Needs a live connection. |
Struct dot s.field, bracket s['field'] |
Partial |
Resolves to the struct column, not the field inside it. |
PIVOT |
Partial |
Grouping columns exact; a pivoted output column maps to the ON and USING columns together. Needs a live connection to enumerate the outputs. |
LATERAL |
Partial |
Over-broad, never wrong: the correlation predicate's columns come along with the projection. |
Columns used only in WHERE, JOIN, GROUP BY, ORDER BY |
Not applicable |
They feed no output column, so they have no lineage entry by construction. Impact analysis finds them through the reference index and labels each hit with its clause. |
Running models
| Feature |
Status |
Notes |
havn transform (whole project) |
Supported |
Content-hash change detection rebuilds only what changed, --force rebuilds everything. |
Selectors on havn transform |
Supported |
The full grammar, positionally or with --select/-s, plus --exclude/-x. havn ls dry-runs a selector without building. |
Job selectors in orchestration/*.yml |
Supported |
The same grammar, plus ingest/export script paths, a job-level exclude:, and resolve: upstream. |
Graph operators (+x, x+, +x+, n+x, x+n, @x) |
Supported |
n+/+n bound the walk to N hops; @x is x, its downstream, and every upstream of those. |
Glob selectors (gold.fct_*, *.customers) |
Supported |
fnmatch anywhere in the name, in either half. |
Selector methods (tag:, path:, config.<key>:, state:modified) |
Supported |
state:modified reads the same content and upstream hashes change detection uses. Comma intersects, e.g. tag:daily,gold.*. |
| Selectors on the API and MCP |
Supported |
POST /api/transform takes targets and exclude; GET /api/models?select= filters the listing; the MCP run_transform and list_models tools take select. |
| Selectors in the web UI |
Supported |
The DAG panel takes a selector, previews what it matches by highlighting those nodes, and runs it as the transform target. The grammar is in the input's tooltip. |
result: and source_status: selectors |
Not supported |
These need a stored result set from the previous run, which havn does not keep per model beyond its status. |
| Environments |
Supported |
havn env use <name> switches the database path, connection overrides and defer target declared in project.yml. |
| Defer to another environment |
Partial |
environments.<name>.defer: <other> plus havn transform --defer attaches that environment's warehouse read-only, so a dev run reads every model it has not built from there and writes only locally. The limitation is DuckDB's file lock: the attach fails whenever another process holds the target open for writing, which is whenever a run against it is going. Not removable, so --defer-snapshot defers to a consistent copy instead (COPY FROM DATABASE, or the newest verified backup when the target is locked). Unlike dbt, no manifest or --state is involved; what havn needs is the other environment's file. |
| Defer on the DuckLake backend |
Not supported |
DuckLake already occupies the ATTACH slot defer would use. A deferred run on it stops with that message rather than half-working. |
| Defer across machines |
Not supported |
The defer target is a warehouse file this machine can open. A remote environment would need a copy on local disk. |
| Defer status in the web UI |
Supported |
The header shows the active environment's defer target with a green dot when the target can be attached read-only right now and amber when it cannot. The tooltip carries the path, why the probe failed, and the --defer-snapshot way out. It describes the instant it was fetched, since the lock can be taken a moment later. |
| Scheduler |
Supported |
Cron schedules in job files, run by havn schedule, plus havn watch for rebuild on file change. |
| Parallel execution |
Supported |
Independent models run concurrently by default; --sequential turns it off. |
Editor (web UI)
The Monaco editor in havn serve.
| Feature |
Status |
Notes |
| Autocomplete |
Supported |
Schemas, tables, columns and macros. |
| Hover |
Supported |
Shows inferred column types for output columns and alias.column references, plus macro signatures and docstrings and table and column metadata. |
| Format and whole-model preview |
Supported |
|
| Live error markers |
Supported |
Bind diagnostics 400 ms after a keystroke, lint diagnostics on save and after 1.5 s idle, under separate marker owners. Only for .sql files under transform/. |
| Go to definition on a model reference |
Supported |
F12 or Ctrl/Cmd+Click on schema.model. Resolves through the model's own path, so @config schema= overrides are followed. Column-level definition is still planned. |
| Preview a single CTE |
Supported |
A Preview code lens above each CTE, or Ctrl/Cmd+Shift+Enter at the cursor. Capped at 100 rows, like the whole-model preview. |
| Rename a column across downstream models |
Supported |
F2 on a column, or havn rename-column. Built on a reference index that sees WHERE, JOIN, GROUP BY, ORDER BY, HAVING and QUALIFY, not only the SELECT list, and follows the column downstream only while it keeps its name. A downstream SELECT * over the column is a blocker, not a silent skip: a star yields no identifier to rewrite. So are COLUMNS(...), UNION BY NAME, a relation-position macro call, an unqualified reference two relations could own, and a metrics or contracts YAML that names the column. --force renames everything else and leaves those for a person. See Refactoring. |
| Find column references |
Supported |
Right-click in the editor, or havn rename-column --dry-run. Lists every site with the clause it sits in and click-to-jump. |
| Multi-file write |
Supported |
PUT /api/files writes a batch as one unit, hash-checking every file first and restoring what it wrote if a later write fails. |
Reuse
| Feature |
Status |
Notes |
Project macros (macros/) |
Supported |
Python and SQL macros, auto-registered. |
| Built-in macro library |
Supported |
Shipped with havn, and a project macro of the same name overrides it. |
| Shared macro packs (pip-installable) |
Planned |
Packages cover the same ground today; this would be the lighter option for sharing macros alone. |
| Packages (shared models across projects) |
Supported |
packages: in project.yml, a git source with a required rev or a local path, installed into havn_packages/ and pinned by havn_packages.lock. See Packages. |
| Package namespacing |
Supported |
A package's schemas become <pkg>_<schema> and its references to its own models are rewritten to match, so a package can never take a name the project was already using. Overridable per schema in the package's havn_package.yml. |
| Package macros |
Supported |
Registered between the built-in library and your project, so your macros win. Module names are package-scoped, so two packages can both ship utils.py. |
| Package version ranges and resolution |
Not supported |
One pinned revision per package, no transitive dependencies and no solver. A package that itself needs a package says so in its README and you install both. |
| Private package authentication |
Partial |
Whatever the machine's git already has: an SSH key, a credential helper, or a token in the URL. havn stores no credentials of its own. |
| Package models in the rest of the tooling |
Supported |
Every command and endpoint that lists or builds the project's DAG sees package models: transform, validate, ls and the selectors, the DAG panel, docs, diff, the MCP tools, sentinel's impact analysis, Pipeline Rewind's downstream walk, both notebook paths, @source_freshness, and unit tests, which can target a package model by its namespaced name. The single-directory listing is kept only where one directory is genuinely what is meant, such as the PR diff comparing two checkouts. |
| Linting installed packages |
Not supported |
havn lint walks the project's own transform/ only. Rewriting a file the next install overwrites would be busywork; lint the package in its own repository. Pointing --fix inside havn_packages/ is refused with that message rather than silently rewriting a file the next install replaces. Checking a package file still reports its violations. |
Testing
| Feature |
Status |
Notes |
@assert data-quality assertions |
Supported |
One expression per line in a model, evaluated against the built model. |
Contracts (contracts/*.yml) |
Supported |
Assertions, freshness windows, severity, notification and escalation all work. Assertions run after the model is built, and a contract whose assertions target a model that does not exist yet reports a missing table. |
| Contract column and type declarations |
Supported |
A columns: block of name, type, optional nullable and description, plus strict: and on_widen:. Checked before the build against the bind pass, so a break is caught on a warehouse where the table does not exist yet, and again after the build against the live table. Missing column, narrowing and a change of type category are errors; an undeclared column is an error only under strict:; a widening warns by default because SUM of a DOUBLE legitimately resolves to DECIMAL(38,1). Type names are validated by DuckDB when the contract loads. See Contracts. |
nullable: false in a contract |
Partial |
Only enforced where DuckDB distinguishes nullable from not-null. Every havn model is built with CREATE TABLE AS, and DESCRIBE reports every such column as nullable, so the check is skipped there rather than failing every declaration. Use no_nulls(column) in assertions: for a data-level guarantee. |
| Schema drift warning without a contract |
Partial |
havn validate can compare the bind-pass schema to the one recorded at the model's last build and report added, removed and retyped columns. Off by default behind validation.schema_drift: warn and --schema-drift, until the equivalence rules have been tuned on a real project. |
| Unit tests (fixed input rows, expected output) |
Supported |
tests/unit/*.yml declares mock upstream rows and the expected output; havn test runs each case on an in-memory DuckDB with the project's macros, reading nothing from the warehouse. An unmocked upstream is an error, not a fallback. Incremental models are tested as a full refresh: incremental_filter and the merge strategy are not exercised. |
| Anomaly detection |
Supported |
Statistical checks over run history, configured under quality.anomaly_detection and surfaced in the web UI. |
havn diff |
Supported |
Row-level diff of what a change would do, before you build it. |
Governance, branches, Python models, reports and live models
| Feature |
Status |
Notes |
| Governed Python (OS-level isolation) |
Partial |
Scripts, notebooks and jobs started by a governed user run in a separate process whose db sends SQL to the server. On Windows the server holds the warehouse file and WAL with an exclusive lock, so the child cannot open them. On Linux and macOS the child runs as the same OS user and could read the file with native code; governance.isolation: strict refuses governed Python there. See Governance. |
| Governed Python and native code |
Partial |
Native code in already-loaded extensions (for example pyarrow.parquet.read_table on a rewind snapshot or backup file) is not seen by the audit hook on any OS. |
Scheduled jobs and macros/ |
Partial |
Scheduled jobs run as the system, and Python files in macros/ run inside the server process, so anyone who can edit them can run unrestricted code. |
| Branch warehouses on DuckLake |
Not supported |
A DuckLake project never resolves to a branch warehouse: DuckLake has no writable fork of a catalog and defer cannot attach a DuckLake base. See Branches. |
Branch following under havn serve --auth |
Not supported |
Users, tokens and masking policies live in the warehouse and a new branch warehouse has none, so the server does not follow checkouts when auth is on. |
| Ad-hoc queries on a branch |
Partial |
Queries in the web UI and havn query see only what the branch warehouse holds. Unbuilt models are read from the base during builds, not by ad-hoc queries. Query the base with --env prod. |
Python models as view, ephemeral or microbatch |
Not supported |
A .py model builds as a table, incremental (not microbatch) or snapshot. View and ephemeral are stored SQL that something reads, and microbatch substitutes {start} and {end} into SQL text. |
| Column lineage through a Python model |
Not supported |
Lineage is not traced through a function. Impact analysis lists a Python consumer as possibly affected and havn rename-column reports it as a blocker to edit by hand. See Python models. |
| Report charts |
Partial |
Chart widgets in scheduled reports need pip install "havn[reports]" (matplotlib). Without it reports carry tables and a text-only PDF. |
| Report schedules |
Partial |
Cron schedules are evaluated in the server's local time by the scheduler that also runs job schedules. |
| Live models: hard deletes downstream |
Partial |
With the default cdc_deletes=hard a deleted key's row is removed, so a live model downstream never sees the delete. Use cdc_deletes=soft and filter WHERE NOT _havn_deleted there. |
| Live models: writes from other processes |
Partial |
The in-process event bus wakes the runner instantly; writes from another process are seen at the next poll_interval. |
| Live models: full-refresh sources |
Not supported |
A table replaced wholesale has no ordering a watermark could follow. |