Lynk SQL
Lynk exposes a SQL interface for querying your semantic layer. The dialect is your warehouse's SQL with two engine-specific abstractions: METRIC('name') for applying a pre-defined aggregation, and USING('relationship_name') for joining along a relationship defined in entities_relationships.yml. Everything else — SELECT, WHERE, GROUP BY, HAVING, ORDER BY, CTEs, subqueries, window functions, every scalar and aggregate function your warehouse exposes — is standard SQL.
The agent uses this syntax internally when generating queries. As an engineer, you write it when authoring evaluation test cases — the expected_output field in an evaluation is a Lynk SQL query.
How it works
Lynk SQL is compiled into your warehouse's native SQL before execution. The engine resolves METRIC() calls to their aggregation expressions, expands USING('relationship_name') into the relationship's ON clause from entities_relationships.yml, and rewrites entity references to the underlying source tables. Everything else passes through to the warehouse.
Two consequences worth knowing:
The dialect is your warehouse's.
FILTER (WHERE ...)works on Postgres;IFF()andQUALIFYwork on Snowflake;PERCENTILE_CONT(...) WITHIN GROUP (...)works on most modern warehouses. If your warehouse doesn't expose a function, neither does Lynk SQL.Some constructs depend on how the engine emits SQL.
WITH RECURSIVE, for example, isn't supported on every engine because of how Lynk generates CTEs. If a construct fails compilation, fall back to a form the engine can express.
Read-only. Lynk SQL compiles to a single SELECT statement. CREATE, INSERT, UPDATE, DELETE, DROP, ALTER, and other DDL/DML are not supported.
Entity references
Entities appear as identifiers in FROM and JOIN — no wrapper, no quoting. The engine resolves the entity to its underlying source table.
SELECT
o.id,
o.status,
o.total_amount
FROM order o
WHERE o.status = 'completed'
ORDER BY o.created_at DESCOne row is returned per entity instance — one row per order in the example above. Field names in SELECT and WHERE are feature names as defined in the entity YAML, not raw warehouse column names. Aliases (FROM order o) work as in any SQL query.
METRIC('<metric_name>')
METRIC('<metric_name>')METRIC('name') applies a pre-defined metric from the entity's metrics: section. Use it anywhere a standard aggregate (SUM, COUNT, AVG) is legal — SELECT, HAVING, arithmetic expressions, CTEs, subqueries, window aggregates.
Rules:
The metric name is a single-quoted string literal:
METRIC('count_orders'), notMETRIC(count_orders).Every
METRIC()call must carry an alias:METRIC('count_orders') AS count_orders.METRIC()resolves against theFROMentity — the main entity in the query. To apply a metric defined on a different entity, push that aggregation into a CTE or subquery (see CTEs and subqueries).Apply
GROUP BYto any non-aggregated features in theSELECT— same rule as standard SQL aggregates.
When the question needs the metric's logic applied to a filtered subset, or combined with a non-aggregate expression that the metric definition doesn't capture, fall back to writing the aggregation manually.
Joins
Lynk SQL supports the full set of standard SQL join types — INNER JOIN, LEFT JOIN, RIGHT JOIN, FULL OUTER JOIN, CROSS JOIN. Pick whichever the question requires. The join condition can be expressed in four forms:
JOIN <entity> (no ON, no USING)
The default relationship between the two entities in entities_relationships.yml is what you want. The engine uses the join marked default: true for that entity pair.
JOIN <entity> USING('relationship_name')
A named relationship exists in entities_relationships.yml and you want that specific one — typically because the entity pair has more than one defined join.
JOIN <entity> USING(<common_feature_name>)
Standard SQL: the two sides share a column/feature name and you want a join on equality of that column. The argument is an unquoted identifier, not a string literal.
JOIN <entity> ON <expr>
No relationship matches, you need extra predicates beyond the relationship's keys, or you're joining a CTE or subquery (where relationships don't apply).
The two USING forms are distinguished by the argument: a single-quoted string literal names a relationship from entities_relationships.yml; an unquoted identifier names a common column.
Default join — no ON, no USING
ON, no USINGWhen two entities have a single join defined in entities_relationships.yml (or one of multiple is marked default: true), join them by name alone. The engine fills in the ON clause from the default relationship.
USING('relationship_name')
USING('relationship_name')When the entity pair has more than one relationship defined, name the one you want with USING() and a string literal. The engine looks up the relationship and expands its ON clause at compile time.
Rules:
The relationship name is a single-quoted string literal.
USING()in this form is only valid for joins predefined inentities_relationships.yml.USING()cannot be combined with additional predicates.USING('rel') AND extra_predicateis invalid — switch to a manualONclause when you need extra filters baked into the join.
USING(<common_feature_name>)
USING(<common_feature_name>)Standard SQL USING — the unquoted identifier names a column/feature that exists on both sides of the join, and the engine joins on equality of that column.
Use this when the two entities (or an entity and a CTE) share a column name and you don't need or want to reference a named relationship.
ON <expr>
ON <expr>Use a manual ON clause when no relationship matches, when the join needs extra predicates beyond the relationship's keys, or when joining a CTE or subquery.
The ON expression is standard SQL — any boolean expression valid in your warehouse works. ON (or column-based USING) is the only join form available when one side is a CTE or subquery, since named relationships are defined between entities, not against derived tables.
CTEs and subqueries
CTEs (WITH ... AS) and subqueries are supported. Two situations make them useful:
Applying a
METRIC()to a filtered subset that the metric definition itself doesn't capture (e.g., the same metric over two distinct time windows in one query).Aggregating from an entity other than the
FROMentity —METRIC()resolves against the main entity, so reach a different entity by isolating it in a CTE or subquery and exposing the aggregated value to the outer query.
Joins to a CTE or subquery use a manual ON clause (or a column-based USING(<column>)) — the relationship-name USING('rel') and the no-clause default-join form apply only to entities defined in entities_relationships.yml.
Reach for a CTE when it earns its place — clearer expression of grain transitions, isolating a filtered metric scope, or splitting a query into named stages. A CTE that exists because you could write one is just noise.
Window functions and QUALIFY
QUALIFYWindow functions are supported, and METRIC() can appear inside the window — both as the aggregated expression and inside OVER (ORDER BY ...). The QUALIFY clause filters rows by a window function result, the way HAVING filters by an aggregate.
All standard window forms work: ROW_NUMBER, RANK, DENSE_RANK, NTILE, PERCENT_RANK, LAG, LEAD, FIRST_VALUE, LAST_VALUE, aggregates as windows (SUM(x) OVER (PARTITION BY ...)), and ROWS BETWEEN ... PRECEDING/FOLLOWING frames.
Set operations and subqueries
UNION, UNION ALL, INTERSECT, and EXCEPT are supported between any two Lynk SQL queries. Subqueries pass through as standard SQL — scalar subqueries in SELECT/WHERE/HAVING, IN (subquery), and EXISTS / NOT EXISTS.
SQL functions
Every scalar, aggregate, and window function your warehouse supports is available. Date math, string operations, conditional expressions, casts (CAST, ::, TRY_CAST) — write them as you would in plain SQL. The engine only intercepts METRIC() and USING(); everything else passes through to the warehouse.
Supported statements
SELECT (incl. DISTINCT)
Yes
FROM <entity>
Yes
JOIN <entity> (default relationship)
Yes
JOIN <entity> USING('relationship_name')
Yes
JOIN <entity> USING(<common_feature_name>)
Yes
JOIN <entity> ON <expr>
Yes
INNER / LEFT / RIGHT / FULL OUTER / CROSS JOIN
Yes
WHERE
Yes
GROUP BY (including by position: GROUP BY 1)
Yes
HAVING (with METRIC() or raw aggregates)
Yes
QUALIFY (window-result filter)
Yes
ORDER BY (with NULLS FIRST / NULLS LAST)
Yes
LIMIT / OFFSET
Yes
CTEs (WITH, WITH RECURSIVE)
Yes
Subqueries (scalar, IN, EXISTS)
Yes
Window functions (OVER, PARTITION BY, ROWS BETWEEN)
Yes
Set operations (UNION, UNION ALL, INTERSECT, EXCEPT)
Yes
Casts (CAST, ::, TRY_CAST)
Yes
DDL / DML
No
Common pitfalls
Wrapping entities in entity('...'). Entities are identifiers in FROM and JOIN — no wrapper. FROM entity('customer') is not valid Lynk SQL; write FROM customer.
Writing METRIC() without quotes, without an alias, or in lowercase. METRIC(count_customers) (missing quotes), METRIC('count_customers') without an AS alias, and metric('count_customers') (lowercase) all fail. The canonical form is METRIC('count_customers') AS count_customers.
Using raw warehouse table names. Lynk SQL operates on entities. FROM db_prod.core.orders bypasses the semantic layer — write FROM order and let the engine resolve the table.
Using {feature_name} curly braces in a query. That syntax is reserved for feature-definition SQL (formula sql:, entity-metric sql:, filter sql:, join sql:). In a Lynk SQL query, reference features by name without braces: WHERE status = 'active', not WHERE {status} = 'active'. The same rule applies to formula features (WHERE customer_tier = 'Enterprise', not WHERE {customer_tier} = 'Enterprise').
Combining USING('rel') with extra predicates. USING('rel') AND extra_predicate is invalid. Switch to a manual ON clause when you need extra filters baked into the join.
Related reference
Metrics — how entity metrics are defined, what
sql:accepts, metric-over-metric compositionEntities — entity anatomy and feature types
Relationships YAML Reference — how join paths are named, defaulted, and configured
Evaluations — where
expected_outputqueries are used
Last updated