Transform data in Hotdata managed databases with dbt — the companion to hotdata-dlt-destination for the T in ELT.
Hotdata is a managed analytics engine (Apache DataFusion, Postgres-dialect SQL) with no DDL surface: tables are created by loading data, not by CREATE TABLE. This adapter embraces that. Every model runs as the Chain pattern, entirely against the API:
- the model's compiled
SELECTexecutes server-side, - the result streams back as Arrow,
- a native load (
replace/append/upsert) applies it to the managed table.
No local database engine, no driver, no version matching — pure Python over HTTPS. The same project runs unchanged on a laptop, in CI, or in a serverless function.
- Requirements
- Install
- Quickstart
- How materializations work
- Feature support
- Configuration
- How it relates to hotdata-dlt-destination
- Development
- License
- Python 3.11+, dbt-core 1.10+
- A Hotdata workspace, an API key, and its workspace ID — from your Hotdata dashboard or the Hotdata CLI.
pip install dbt-hotdata
# or
uv add dbt-hotdataprofiles.yml
my_project:
target: dev
outputs:
dev:
type: hotdata
workspace_id: your_workspace_id
# database_id: db_abc123 # pin after the first run — see below
schema: public
threads: 1Set your API key in the environment (it's a secret; the workspace ID is routing, not a credential):
export HOTDATA_API_KEY=your_api_keydbt_project.yml — Hotdata has no views, and dbt's default materialization is view, so set the project default to table:
models:
my_project:
+materialized: tableThen:
dbt runOn first run, a managed database labelled dbt is created automatically and its id is printed:
hotdata: created managed database db_abc123 (name='dbt'). Pin it for future runs by
setting database_id: db_abc123 in profiles.yml.
Managed databases are addressed by id — Hotdata database names are not unique, so a name can't identify one. Pin database_id in the profile to keep building into the same database; without it, each run creates a fresh one (useful for CI or per-branch runs — databases can be set to expire).
-- models/events_rollup.sql
{{ config(
materialized='incremental',
incremental_strategy='merge',
unique_key='event_id'
) }}
select event_id, user_id, count(*) as touches, max(occurred_at) as last_seen
from {{ source('app', 'events') }}
{% if is_incremental() %}
where occurred_at > (select max(last_seen) from {{ this }})
{% endif %}
group by event_id, user_idmerge runs as a native server-side upsert matched on unique_key — updates matches, inserts the rest, no full-table read. append (the default strategy) adds the new rows. First runs and --full-refresh load with replace.
| Materialization | What happens |
|---|---|
table |
Model SQL runs server-side → result loads with native replace. No temp table, no rename swap (there is no rename). |
incremental |
Same, with append (default) or upsert (incremental_strategy: merge + unique_key, composite keys supported). |
seed |
The CSV becomes Arrow (numbers stay exact — integers and decimals, never silently floats), then a replace load. column_types: are applied as Arrow casts. |
ephemeral |
Standard dbt — inlined into consumers, nothing built. |
view |
❌ Fails up front: Hotdata has no views. The error tells you to set +materialized: table. |
snapshot |
❌ Fails up front: merges update rows in place, so past versions aren't kept. Keep history with an append incremental model. |
Tests, dbt show, analyses, and source freshness all run as plain SELECTs on the server. dbt docs generate builds the catalog from the managed-table API plus Arrow schema probes.
Schema evolution is additive and automatic: a model that starts producing a new column just includes it in the next load — existing data is never touched, and types can widen but never silently shrink. (on_schema_change is therefore ignored.)
| Feature | Support | Notes |
|---|---|---|
table, incremental, seed, ephemeral |
✅ | See above |
| Incremental strategies | append, merge (native upsert by unique_key). No delete+insert, no microbatch |
|
view, snapshot |
❌ | Clear error up front |
| Tests (generic + singular) | ✅ | Run server-side; store_failures supported |
dbt docs generate |
✅ | Catalog from the managed-table API |
| Source freshness | ✅ | loaded_at_field queries run server-side |
Hooks (pre-hook/post-hook, on-run-*) |
Run server-side — SELECT-shaped SQL only (no DDL exists) | |
| Python models | ❌ | |
| Model contracts / constraints | ❌ | No DDL; dbt warns they are unenforced |
| Grants | ❌ | Ignored with a warning — access is governed by workspace API keys |
| Transactions | ❌ | begin/commit are no-ops (DataFusion has no transactions) |
| Query cancellation | ❌ | An in-flight HTTPS query can't be interrupted client-side |
| Profile field | Env variable | Default | Description |
|---|---|---|---|
api_key |
HOTDATA_API_KEY |
required | API key (a secret — prefer the env var or "{{ env_var('HOTDATA_API_KEY') }}") |
workspace_id |
— | required | Workspace ID (routing, not a secret) |
database_id |
— | — | Id of the managed database to build into. This is how a database is targeted — names aren't unique. Printed on first-run create; pin it to reuse |
database_name |
— | dbt |
Display label used only when creating a new database (never to look one up) |
schema |
— | public |
Schema inside the managed database |
create_database_if_missing |
— | true |
Create a database on first run when no database_id is pinned |
api_base_url |
— | https://api.hotdata.dev |
API endpoint |
max_retries |
— | 8 |
Retry budget for transient errors (409/429/5xx). Loads take a catalog-level lock per database; ~42s of linear backoff outlasts a concurrent writer |
retry_backoff_seconds |
— | 1.5 |
Initial retry wait (grows linearly) |
threads |
— | 1 |
Loads into one database serialize server-side (contention is retried); more threads still help when models spend most of their time in query execution |
database: stays unset — inside a managed database the SQL catalog is always literally default (relations render as "default"."schema"."table"), and the adapter rejects any other value up front.
hotdata-dlt-destination loads external data into Hotdata (the EL); this adapter transforms it inside Hotdata (the T). They share the same conventions — HOTDATA_API_KEY from the environment, workspace_id as a plain parameter, id-first database_id addressing, the same retry classification — and the same underlying SDK (hotdata + hotdata-framework). Point dbt at the database_id your dlt pipeline prints, add sources for the loaded tables, and build models on top.
The project uses uv for dependency management.
git clone https://github.com/hotdata-dev/dbt-hotdata.git
cd dbt-hotdata
uv sync # install deps (including dev group)
uv run pytest # run the test suite (offline — no credentials needed)
uv run ruff check # lint
uv run ruff format # format
uv run mypy # type-checkThe test suite runs entirely offline: adapter logic is exercised against an in-memory fake client, and a real dbt parse verifies plugin registration and every macro.
MIT © Hotdata Inc.