ASQL for dbt Users¶
ASQL integrates naturally with dbt. The simplest way to think about it:
- dbt still owns the project: models, materializations, tests, docs, exposures, deployments.
- ASQL is what you write inside a model: a cleaner, more readable query that dbt materializes.
ASQL is also heavily inspired by the “dbt style” of analytics engineering: readable transformations, conventions, and (often) dbt_utils-style helpers to avoid repetitive SQL.
What will feel familiar¶
- Model-first workflow: one model = one query you can reason about.
- CTE-shaped thinking: ASQL pipelines feel like a tidy, linear CTE stack.
- dbt_utils patterns: pivot/unpivot, date spines, dedupe, surrogate keys, unioning relations.
Quick start: your first dbt model in ASQL¶
In a dbt model file, you generally keep the dbt control plane (config, refs, incremental logic) the same, and write the query itself in ASQL style:
-- models/orders_summary.sql
{{ config(materialized='table') }}
from {{ ref('orders') }}
where status = 'completed'
group by month(created_at) (
sum(amount) as revenue,
count(distinct customer_id) as customers
)
order by -month
If you’re used to leaning on dbt macros for common transformations, the nice surprise is: many of those “macro” patterns become built-in ASQL syntax/functions (so your model query can stay pure and readable).
Quick Reference¶
| dbt_utils-style macro pattern | ASQL built-in | Notes |
|---|---|---|
{{ dbt_utils.star() }} |
Default behavior | ASQL returns all columns by default |
{{ dbt_utils.star(except=[...]) }} |
except col1, col2 |
Exclude columns |
{{ dbt_utils.generate_surrogate_key([...]) }} |
key(col1, col2) |
Planned (inspired by dbt macros) |
{{ dbt_utils.pivot() }} |
pivot ... values ('A', 'B') |
Rows to columns (static values list) |
{{ dbt_utils.unpivot() }} |
unpivot ... into ... |
Columns to rows |
{{ dbt_utils.date_spine() }} |
auto-spine | Gap filling is automatic for date GROUP BYs |
{{ dbt_utils.deduplicate() }} |
per id first by -date |
Remove duplicates |
{{ dbt_utils.union_relations() }} |
(future) | Not implemented yet |
Built-in “macros” (dbt_utils-style helpers, without Jinja)¶
dbt macros vs ASQL built-ins
You’ll still use dbt’s core Jinja macros (like ref(), source(), this, var(), is_incremental()) for dependency wiring and model logic.
The point here is that many transformation helpers you might have reached for via dbt_utils-style macros are simply built into ASQL itself.
dbt macros are great, but they can also hide a lot of SQL and make it harder to see what a model does. In ASQL, many of the most common macro-shaped transformations are first-class language features.
Below are the equivalents you can reach for directly in ASQL, without wrapping your query in Jinja.
star() and star(except=...) → default select and except¶
- Default: ASQL returns all columns unless you narrow it with
select. - Exclude columns: use
exceptto drop noisy or sensitive fields.
from users
except password_hash, ssn
generate_surrogate_key() → key(...)¶
Use key(col1, col2, ...) to build a stable surrogate key from one or more columns.
Status: Planned (inspired by dbt macros). See the repo’s GitHub issues for the current implementation status.
from orders
select *, key(user_id, order_id) as order_key
Features: - Deterministic: Same inputs always produce the same hash - NULL handling: NULLs are converted to empty strings before hashing, ensuring consistent results - Type normalization: All values are cast to strings before concatenation - Cross-dialect: Automatically uses appropriate hash function for your SQL dialect (MD5, SHA256, etc.)
deduplicate() → per ... first by ...¶
Most “latest row per key” / “one row per entity” macros are a window function pattern. In ASQL, it’s a one-liner:
from events
per user_id, event_type first by -created_at
date_spine() → auto-spine (default)¶
ASQL’s compiler already provides auto-spine (enabled by default) which gap-fills date group-bys when you filter to a range.
pivot() / unpivot() → pivot / unpivot¶
Pivoting is intentionally terse in ASQL:
from issue_custom_fields
pivot field_value by field_name values ('priority', 'status', 'assignee')
And unpivoting (“pivot longer”) is equally direct:
from monthly_data
unpivot jan, feb, mar into month, value
union_relations() → (future) from union(...)¶
Not implemented yet. (This is a plausible future convenience wrapper for schema-aligned unions.)
safe_cast() → (future) ::type? (and defaults with ??)¶
Safe casts are not implemented yet. For now, use dialect-specific SQL (TRY_CAST, SAFE_CAST) directly.
coalesce() → ??¶
Null coalescing is an operator, so it reads left-to-right:
first_name ?? nickname ?? 'Unknown'
star() / except¶
SELECT
{{ dbt_utils.star(from=ref('users'), except=['password_hash', 'ssn']) }}
FROM {{ ref('users') }}
from users
except password_hash, ssn
Surrogate Keys¶
SELECT
{{ dbt_utils.generate_surrogate_key(['user_id', 'order_id']) }} as order_key,
*
FROM {{ ref('orders') }}
from orders
-- Planned helper inspired by dbt_utils.generate_surrogate_key
-- (See GitHub issues)
select *, key(user_id, order_id) as order_key
Date Spine¶
{{ dbt_utils.date_spine(
datepart="day",
start_date="cast('2020-01-01' as date)",
end_date="current_date"
) }}
-- Auto-spine fills gaps automatically for date GROUP BYs
-- when you filter to a date range.
from orders
where created_at >= @2024-01-01 and created_at < @2025-01-01
group by month(created_at) (
sum(amount) ?? 0 as revenue
)
Gap Filling Time Series¶
-- Complex CTE pattern with date_spine and left join
WITH spine AS (
{{ dbt_utils.date_spine(...) }}
),
data AS (
SELECT date_trunc('month', created_at) as month, sum(amount) as revenue
FROM {{ ref('orders') }}
GROUP BY 1
)
SELECT
spine.date_month,
COALESCE(data.revenue, 0) as revenue
FROM spine
LEFT JOIN data ON spine.date_month = data.month
# Auto-spine fills gaps automatically for date GROUP BYs
from orders
where created_at >= @2024-01-01 and created_at < @2025-01-01
group by month(created_at) (
sum(amount) ?? 0 as revenue # ?? 0 sets default for filled rows
)
Deduplication¶
{{ dbt_utils.deduplicate(
relation=ref('events'),
partition_by='user_id, event_type',
order_by='created_at desc'
) }}
from events
per user_id, event_type first by -created_at
Pivot¶
-- Fivetran dbt packages often have hundreds of lines for this
{{ dbt_utils.pivot(
column='field_name',
values=['priority', 'sprint', 'story_points'],
then_value='field_value'
) }}
-- Denormalize Jira/Salesforce custom fields
from issue_custom_fields
pivot field_value by field_name values ('priority', 'status', 'assignee')
Union Relations¶
{{ dbt_utils.union_relations(
relations=[ref('users_2022'), ref('users_2023'), ref('users_2024')]
) }}
-- Not implemented yet (future convenience wrapper)
-- from union(users_2022, users_2023, users_2024)
Safe Casting¶
-- Different per warehouse
{{ adapter.dispatch('safe_cast', 'dbt_utils')('value', 'integer') }}
-- Or manually
TRY_CAST(value AS INTEGER) -- Snowflake
SAFE_CAST(value AS INT64) -- BigQuery
-- Not implemented yet; use dialect-specific SQL for now
-- TRY_CAST(value AS INTEGER) -- Snowflake
-- SAFE_CAST(value AS INT64) -- BigQuery
Incremental Models¶
dbt's incremental logic stays in dbt - ASQL focuses on the query:
-- models/orders_summary.sql
{{ config(materialized='incremental') }}
-- ASQL query here
from orders
where status = 'completed'
group by month(created_at) (
sum(amount) as revenue,
count(distinct customer_id) as customers
)
{% if is_incremental() %}
where created_at > (select max(created_at) from {{ this }})
{% endif %}
COALESCE¶
COALESCE(first_name, nickname, 'Unknown')
first_name ?? nickname ?? 'Unknown'