ASQL for SQL Users¶
If you’re already comfortable with SQL, you’re going to be productive in ASQL immediately. ASQL is designed by (and for) people who live in SQL every day, and many of its features are directly inspired by common SQL patterns—just expressed with friendlier syntax.
ASQL is still “real SQL” (it compiles to your warehouse dialect), but it’s designed to read like a clean transformation pipeline instead of a nested, back-and-forth SQL statement.
What will feel familiar¶
- The core building blocks: filters, projections, joins, grouping, ordering, limits.
- SQL expressions: arithmetic, comparisons, boolean logic, functions.
- The end result: a query your warehouse can optimize and execute.
What’s different (in a good way)¶
- Top-to-bottom pipelines: start with
from ..., then apply transforms line-by-line. - Less ceremony: fewer CTEs, fewer “select lists as the control plane”, fewer giant macro helpers.
- Ergonomic shortcuts: things like
??,-colfor descending, andper ...for common window patterns.
Key Differences¶
| SQL | ASQL | Why |
|---|---|---|
SELECT ... FROM |
from ... select |
Pipeline order - start with data |
ORDER BY x DESC |
order by -x |
Cleaner descending syntax |
CASE WHEN ... END |
when ... otherwise |
Less verbose |
COALESCE(a, b) |
a ?? b |
Modern null coalescing |
CAST(x AS int) |
x::int |
Postgres-style casting |
| CTEs everywhere | Pipeline + comments | Often no CTEs needed |
| Manual aliases everywhere | Auto-aliasing | sum(amount) → sum_amount automatically |
Your first ASQL query (5 minutes)¶
If you can write:
1) FROM table
2) add a WHERE
3) add a GROUP BY
4) add an ORDER BY
…then you can write ASQL. The main “reversal” is that you start with the dataset and then refine it.
The Pipeline Difference¶
SELECT
region,
SUM(amount) as revenue,
COUNT(DISTINCT customer_id) as customers
FROM orders
WHERE status = 'completed'
AND created_at >= '2024-01-01'
GROUP BY region
HAVING SUM(amount) > 10000
ORDER BY revenue DESC
LIMIT 10
from orders
where status = 'completed'
where created_at >= @2024-01-01
group by region (
sum(amount) as revenue,
count(distinct customer_id) as customers
)
where revenue > 10000
order by -revenue
limit 10
Reading order = execution order
In ASQL, you read top-to-bottom and that's the order operations happen. No more jumping between SELECT and FROM.
Joins¶
SELECT o.*, c.name as customer_name
FROM orders o
LEFT JOIN customers c ON o.customer_id = c.id
from orders
&? customers on orders.customer_id = customers.id
select orders.*, customers.name as customer_name
-- Or with dot notation (auto-join via FK convention)
from orders
select *, orders.customer.name as customer_name
Join Operators¶
| SQL | ASQL Operator | Meaning |
|---|---|---|
INNER JOIN |
& |
Both sides must match |
LEFT JOIN |
&? |
Right side is optional (can be NULL) |
RIGHT JOIN |
?& |
Left side is optional |
FULL OUTER JOIN |
?&? |
Both sides are optional |
CROSS JOIN |
* |
Cartesian product |
Mnemonic: The ? marks the side that might be NULL.
CASE Statements¶
SELECT
id,
CASE
WHEN amount > 1000 THEN 'high'
WHEN amount > 100 THEN 'medium'
ELSE 'low'
END as tier
FROM orders
from orders
select id,
when amount
> 1000 then 'high'
> 100 then 'medium'
otherwise 'low'
as tier
Simple CASE (Equality)¶
CASE status
WHEN 'A' THEN 'Active'
WHEN 'I' THEN 'Inactive'
ELSE 'Unknown'
END
when status
is 'A' then 'Active'
is 'I' then 'Inactive'
otherwise 'Unknown'
Window Functions¶
SELECT *,
ROW_NUMBER() OVER (
PARTITION BY customer_id
ORDER BY created_at DESC
) as rn
FROM orders
QUALIFY rn = 1
-- For deduplication, use per
from orders
per customer_id first by -created_at
-- For adding row numbers
from orders
per customer_id number by -created_at
Window Function Shortcuts¶
| SQL Pattern | ASQL Shortcut |
|---|---|
ROW_NUMBER() OVER (PARTITION BY x ORDER BY y) = 1 |
per x first by y |
ROW_NUMBER() OVER (PARTITION BY x ORDER BY y) |
per x number by y |
RANK() OVER (PARTITION BY x ORDER BY y) |
per x rank by y |
LAG(col, 1) OVER (ORDER BY x) |
prior(col) |
LEAD(col, 1) OVER (ORDER BY x) |
next(col) |
SUM(col) OVER (ORDER BY x ROWS UNBOUNDED PRECEDING) |
running_sum(col) |
AVG(col) OVER (ORDER BY x ROWS 6 PRECEDING) |
rolling_avg(col, 7) |
CTEs¶
WITH active_users AS (
SELECT * FROM users WHERE is_active
),
user_orders AS (
SELECT
u.id,
u.name,
COUNT(*) as order_count
FROM active_users u
JOIN orders o ON u.id = o.user_id
GROUP BY u.id, u.name
)
SELECT * FROM user_orders
WHERE order_count > 5
ORDER BY order_count DESC
-- Often no CTE needed - just pipeline
from users
where is_active
& orders on users.id = orders.user_id
group by users.id, users.name (
# as order_count
)
where order_count > 5
order by -order_count
-- Or use stash for reusable parts
from users
where is_active
stash as active_users
from active_users
& orders on active_users.id = orders.user_id
group by active_users.id, active_users.name (
# as order_count
)
where order_count > 5
order by -order_count
Dates¶
-- Dialect varies!
DATE_TRUNC('month', created_at) -- Postgres
DATE_TRUNC(created_at, MONTH) -- BigQuery
TRUNC(created_at, 'MONTH') -- Oracle
DATETRUNC(MONTH, created_at) -- SQL Server
CURRENT_DATE - INTERVAL '7 days' -- Postgres
DATE_SUB(CURRENT_DATE(), INTERVAL 7 DAY) -- BigQuery
-- Same syntax everywhere, compiles to right dialect
month(created_at)
7 days ago
today() - 7 days
Date Functions¶
| Operation | ASQL | Notes |
|---|---|---|
| Truncate to month | month(created_at) |
Also: year(), week(), day(), hour() |
| Day of week | day of week created_at |
Returns 1-7 |
| Date arithmetic | order_date + 7 days |
Also: weeks, months, years |
| Date difference | days(end_date - start_date) |
Returns integer |
| Relative dates | 7 days ago |
Also: 3 days from now |
| Time since | days_since_created_at |
Shorthand for days(now() - created_at) |
Null Handling¶
COALESCE(first_name, nickname, 'Unknown')
NULLIF(amount, 0)
first_name ?? nickname ?? 'Unknown'
when amount = 0 then null, else amount
Type Casting¶
CAST(value AS INTEGER)
-- Safe casting (varies by dialect)
TRY_CAST(value AS INTEGER) -- Snowflake
SAFE_CAST(value AS INT64) -- BigQuery
value::integer
-- Safe casting (returns NULL on failure)
value::integer?
-- With default
value::integer? ?? 0
Aggregations in SELECT¶
SELECT
region,
SUM(amount) as total,
COUNT(*) as count,
COUNT(DISTINCT customer_id) as unique_customers
FROM orders
GROUP BY region
from orders
group by region (
sum(amount) as total,
# as count,
count(distinct customer_id) as unique_customers
)
The parentheses after group by contain the aggregations - clearer than mixing them in SELECT.
Auto-Aliasing: No More Manual Column Names¶
One of SQL's biggest annoyances: every function needs an explicit alias or you get unusable column names.
-- Without aliases, you get useless names
SELECT
SUM(amount), -- Column name: "sum" or "SUM(amount)" or "f0_"
AVG(amount), -- Column name: "avg" or "AVG(amount)" or "f1_"
COUNT(*) -- Column name: "count" or "COUNT(*)" or "f2_"
FROM orders
GROUP BY region
-- Can't reference these in ORDER BY without aliases!
-- Must add aliases everywhere:
SELECT
SUM(amount) AS sum_amount,
AVG(amount) AS avg_amount,
COUNT(*) AS num
FROM orders
GROUP BY region
ORDER BY sum_amount DESC
from orders
group by region (
sum(amount), -- Auto-alias: sum_amount
avg(amount), -- Auto-alias: avg_amount
count(*) -- Auto-alias: num
)
order by -sum_amount -- Just works!
ASQL generates meaningful column names automatically:
| Function | Auto-Generated Name |
|---|---|
sum(amount) |
sum_amount |
avg(price) |
avg_price |
count(*) / # |
num |
month(created_at) |
month_created_at |
count(distinct user_id) |
num_distinct_user_id |
See the Auto-Aliasing Reference for the complete mapping table and configuration options.