How to Query a Semantic View with Standard SQL¶
Added in version 0.17.0.
This guide shows how to query a semantic view by name with an ordinary SELECT, as you would a table, the way Snowflake allows. You list the dimensions you want in GROUP BY and pass each metric to AGG:
SELECT region, AGG(revenue)
FROM analytics
WHERE channel = 'web'
GROUP BY region;
The extension turns the query into the SEMANTIC_VIEW(…) construct before DuckDB plans it, so the results, the checks (fan traps, role-playing paths, private members) and the errors are the construct’s. The query above is the same as:
SELECT * FROM SEMANTIC_VIEW(analytics
DIMENSIONS region
METRICS revenue
WHERE channel = 'web'
);
Use this form when you, or a tool that writes SQL, would rather write a familiar GROUP BY query than the construct. Both return the same rows.
Prerequisites:
The extension loaded (see Getting Started)
A semantic view to query; the examples use the one defined below
Set Up an Example View¶
Every example below runs against this view of orders and the customers who placed them. amount is declared as a fact, so it can be aggregated ad hoc or returned row by row.
CREATE TABLE customers (id INTEGER, name VARCHAR, region VARCHAR);
INSERT INTO customers VALUES
(1, 'Acme', 'East'),
(2, 'Globex', 'East'),
(3, 'Initech', 'West');
CREATE TABLE orders (
id INTEGER, customer_id INTEGER, amount DECIMAL(10,2),
channel VARCHAR, ordered_at DATE
);
INSERT INTO orders VALUES
(1, 1, 100.00, 'web', DATE '2023-12-15'),
(2, 1, 250.00, 'web', DATE '2024-01-10'),
(3, 2, 80.00, 'retail', DATE '2024-02-03'),
(4, 3, 300.00, 'retail', DATE '2024-03-21'),
(5, 3, 150.00, 'web', DATE '2024-04-02');
CREATE SEMANTIC VIEW analytics AS
TABLES (
o AS orders PRIMARY KEY (id),
c AS customers PRIMARY KEY (id)
)
RELATIONSHIPS (
order_customer AS o(customer_id) REFERENCES c
)
FACTS (
o.amount AS o.amount
)
DIMENSIONS (
c.customer AS c.name,
c.region AS c.region,
o.channel AS o.channel,
o.ordered_at AS o.ordered_at
)
METRICS (
o.revenue AS SUM(o.amount),
o.order_count AS COUNT(*)
);
The query from the top of the page returns web revenue by region:
SELECT region, AGG(revenue)
FROM analytics
WHERE channel = 'web'
GROUP BY region
ORDER BY region;
┌────────┬──────────────┐
│ region │ agg(revenue) │
├────────┼──────────────┤
│ East │ 350.00 │
│ West │ 150.00 │
└────────┴──────────────┘
How the Clauses Map¶
In the query |
Becomes |
|---|---|
|
|
|
The metric |
Any other aggregate, such as |
An ad-hoc metric in |
A |
A |
|
The construct’s |
|
A filter on the result, after the metrics are computed. It may refer only to metrics passed to |
|
Applied to the result, as for any query. |
Each column is named as DuckDB would name the SELECT item: region, agg(revenue), or the alias you give it. Give each AGG an alias when the column name matters to the code that reads it:
SELECT region, AGG(revenue) AS revenue, AGG(order_count) AS orders
FROM analytics
GROUP BY ALL
ORDER BY region;
┌────────┬─────────┬────────┐
│ region │ revenue │ orders │
├────────┼─────────┼────────┤
│ East │ 430.00 │ 3 │
│ West │ 450.00 │ 2 │
└────────┴─────────┴────────┘
Compute Over Metrics and Dimensions¶
Expressions around AGG are computed on the result, and expressions over grouped dimensions are dimension expressions. HAVING keeps the years with more than one order:
SELECT year(ordered_at) AS y,
AGG(revenue) / AGG(order_count) AS avg_order
FROM analytics
GROUP BY year(ordered_at)
HAVING AGG(order_count) > 1
ORDER BY y;
┌──────┬───────────┐
│ y │ avg_order │
├──────┼───────────┤
│ 2024 │ 195.0 │
└──────┴───────────┘
2023 had one order, so HAVING drops it after the metrics are computed.
Aggregate Ad Hoc and Return Fact Rows¶
An aggregate other than AGG is computed over the dimensions and facts it names, like an expression item in METRICS. Use it for a calculation the view does not declare:
SELECT region,
AGG(revenue) AS revenue,
MAX(amount) AS biggest_order,
COUNT(DISTINCT customer) AS customers
FROM analytics
GROUP BY region
ORDER BY region;
┌────────┬─────────┬───────────────┬───────────┐
│ region │ revenue │ biggest_order │ customers │
├────────┼─────────┼───────────────┼───────────┤
│ East │ 430.00 │ 250.00 │ 2 │
│ West │ 450.00 │ 300.00 │ 1 │
└────────┴─────────┴───────────────┴───────────┘
A fact in a query with no GROUP BY returns row-level values, one row per order here, as FACTS amount would in the construct. In a grouped query, a fact must be grouped on or aggregated, as any column must in SQL. Grouped on, it gives one row per distinct value, which here is one row per order:
SELECT customer, amount
FROM analytics
GROUP BY customer, amount
ORDER BY customer, amount;
┌──────────┬────────┐
│ customer │ amount │
├──────────┼────────┤
│ Acme │ 100.00 │
│ Acme │ 250.00 │
│ Globex │ 80.00 │
│ Initech │ 150.00 │
│ Initech │ 300.00 │
└──────────┴────────┘
Refer to Members¶
Members are named as in the construct. A name used by members of two tables is qualified by the logical table’s alias (c.region). A qualifier that names the view itself, by its alias in FROM or by its name, is dropped. The view itself can be named with its schema, and database, as a table can (FROM main.analytics):
SELECT s.region, AGG(s.revenue) AS revenue
FROM analytics s
GROUP BY s.region
ORDER BY s.region;
┌────────┬─────────┐
│ region │ revenue │
├────────┼─────────┤
│ East │ 430.00 │
│ West │ 450.00 │
└────────┴─────────┘
Use It Anywhere a Query Goes¶
The query can stand wherever a SELECT can: in a subquery or CTE, joined (as a subquery) with tables, in UNION, in CREATE TABLE ... AS, INSERT ... SELECT, a subquery of UPDATE, DELETE or SET VARIABLE, COPY (...) TO, EXPLAIN, DESCRIBE, a prepared statement, or a CREATE VIEW (which stores the construct form). A subquery in any clause counts, ORDER BY and LIMIT included.
The query block that reads the view cannot join other tables, so join its result instead. Here each region’s revenue is compared with a target held in an ordinary table:
CREATE TABLE region_targets (region VARCHAR, target DECIMAL(10,2));
INSERT INTO region_targets VALUES ('East', 400.00), ('West', 500.00);
SELECT r.region, r.revenue, t.target, r.revenue >= t.target AS met
FROM (
SELECT region, AGG(revenue) AS revenue
FROM analytics
GROUP BY region
) r
JOIN region_targets t USING (region)
ORDER BY r.region;
┌────────┬─────────┬────────┬───────┐
│ region │ revenue │ target │ met │
├────────┼─────────┼────────┼───────┤
│ East │ 430.00 │ 400.00 │ true │
│ West │ 450.00 │ 500.00 │ false │
└────────┴─────────┴────────┴───────┘
Window functions work the same way: compute the metrics in a CTE, then rank the result:
WITH r AS (
SELECT region, AGG(revenue) AS revenue
FROM analytics
GROUP BY region
)
SELECT region, revenue, rank() OVER (ORDER BY revenue DESC) AS rnk
FROM r
ORDER BY rnk;
┌────────┬─────────┬─────┐
│ region │ revenue │ rnk │
├────────┼─────────┼─────┤
│ West │ 450.00 │ 1 │
│ East │ 430.00 │ 2 │
└────────┴─────────┴─────┘
Pass Values from Application Code¶
A prepared-statement parameter (?, $1, $name) in WHERE is a parameter of your statement, as in the construct’s WHERE, so a value from a request is bound as a value and never pasted into the SQL:
PREPARE web_revenue AS
SELECT region, AGG(revenue) AS revenue
FROM analytics
WHERE channel = ?
GROUP BY region
ORDER BY region;
EXECUTE web_revenue('web');
┌────────┬─────────┐
│ region │ revenue │
├────────┼─────────┤
│ East │ 350.00 │
│ West │ 150.00 │
└────────┴─────────┘
From Python, duckdb.sql(), con.sql() and con.execute() all accept the query, with parameters bound the usual way:
import datetime
import duckdb
con = duckdb.connect("analytics.duckdb") # the database holding the view
con.execute("LOAD semantic_views")
def revenue_by_region(channel, since):
return con.execute(
"""
SELECT region, AGG(revenue) AS revenue
FROM analytics
WHERE channel = ? AND ordered_at >= ?
GROUP BY region
ORDER BY region
""",
[channel, since],
).fetchall()
revenue_by_region("web", datetime.date(2024, 1, 1))
# [('East', Decimal('250.00')), ('West', Decimal('150.00'))]
A relation built over the query can be read by its variable name in a later query, as DuckDB reads any relation. A Python DataFrame or relation that has the view’s own name is read as itself, not as the view. Pass a Request Value from Application Code covers the safety rules for request values in more depth.
Verify the Generated SQL¶
EXPLAIN on a standard-SQL query shows DuckDB’s plan, not the SQL the extension writes. To see that SQL, pass the same request to explain_semantic_view(), using the mapping above: the GROUP BY items as dimensions, the metrics in AGG as metrics, and the WHERE as where_clause. For the query at the top of the page:
SELECT * FROM explain_semantic_view('analytics',
dimensions := ['region'],
metrics := ['revenue'],
where_clause := 'channel = ''web'''
);
The output shows the expanded SQL, then DuckDB’s plan for it. The SQL has a SUM over orders, a LEFT JOIN to customers for region, and channel = 'web' applied before the GROUP BY. HAVING, ORDER BY and LIMIT run on that result, so they do not appear in it.
What Is Refused¶
These follow Snowflake’s rules for the query form, and each is refused with an error naming the view (the messages are listed under Standard-SQL query refused):
A metric not passed to
AGG(SELECT region, revenue ...).A dimension in
SELECTorORDER BYthat is not inGROUP BY.HAVINGover anything but aggregated metrics; filter dimensions inWHERE.In the query block that reads the view:
SELECT *, a join (or aFROMlist) with other tables,PIVOT(also thePIVOT v ON ...statement), window functions,QUALIFY,GROUPING SETS/ROLLUP/CUBE, sampling, and a column alias list on the view. Query the view in a subquery or CTE and apply these to its result, as in Use It Anywhere a Query Goes.A reference to a table of an enclosing query (a correlated subquery), written qualified, as in
WHERE region = c.region. Inside the construct the name would otherwise resolve to the view’s own logical table.A bare name in
WHEREthat is no member of the view is passed to DuckDB as written, as a raw column in the construct’sWHEREis. When none of the view’s tables has a column of that name, DuckDB binds it to the enclosing query, soWHERE region = nm, withnma column of the outer query, is a working correlated subquery. A bare name that is a member always means the member, and a name that is a column of one of the view’s tables binds to that column, so the outer column is reached only when no table of the view has one by that name.
A name that is a table, a view, a CTE in scope or a Python DataFrame or relation is not a semantic view here: those keep their usual meaning, and so do DuckDB’s own errors for statements that read no semantic view.
This form has no way to set the view’s variables, so each takes its default. A view with a variable that has no default cannot be queried this way: the query fails with variable '<name>' has no default. Use SEMANTIC_VIEW(…) with a VARIABLES clause for such a view.
Troubleshooting¶
- metric ‘revenue’ must be passed to AGG, e.g. AGG(revenue)
A metric is already aggregated, so it is requested with
AGG(revenue), not by its bare name. This also applies inHAVING: aSELECTalias that repeats a metric’s name still names the metric there, so writeHAVING AGG(revenue) > 400rather thanHAVING revenue > 400.- dimension ‘customer’ must appear in GROUP BY
Every dimension you select is a grouping key. Add it to
GROUP BY, or writeGROUP BY ALL.- ‘amount’ must be passed to an aggregate or appear in GROUP BY
In a grouped query, every
SELECTitem is grouped on or aggregated. Either the item is a fact you meant to aggregate (writeMAX(amount)) or group by, or its name is misspelled:SELECT region, regoin, AGG(revenue) ... GROUP BY regiongives this error too. Check the name against SHOW COLUMNS IN SEMANTIC VIEW. To read a dimension row by row next to a fact, use the construct’sFACTS(see Combine Facts with Dimensions).- cannot combine facts and metrics in the same query
In a query with no
GROUP BY, aSELECTitem that is neither grouped nor aggregated is read as a fact, and a query returns either row-level facts or aggregated metrics, not both. Aggregate the fact, or check its name as above.- SELECT * is not supported in a query over a semantic view
The query form needs the dimensions and metrics named, as in Snowflake. Name them, or use
SELECT * FROM SEMANTIC_VIEW(...), where*means the items you listed.- semantic view ‘analytics’ cannot be joined, pivoted or read alongside other tables
Query the view on its own in a subquery or CTE and join that result, as in Use It Anywhere a Query Goes.
- Catalog Error: Table with name … does not exist
The name in
FROMis not a semantic view, table or view. Check the spelling with SHOW SEMANTIC VIEWS. If a CTE or table has the same name as the semantic view, the query reads that instead; rename one of them.