For the complete documentation index, see llms.txt. This page is also available as Markdown.

Anchor SQL Reference

Anchor SQL is the platform's query language over the ontology. It is SQL with the table layer removed: you name concepts, and the ontology decides which sources hold them, how to join those sources, how to align their time columns, and how to match their locations.

The same language runs everywhere: over the REST API (POST /api/v1/workspaces/{wsId}/ontology/query), from the Python SDK (Ontology.sql), and inside the connected assistant.


The mental model

A concept is the shared meaning of a column across every source that carries it. "Invoice Total" may live in a billing export, "Support Tickets" in a helpdesk extract, "Contract Type" in a CRM table, and the place names in a geo enrichment sidecar — but there is one concept for each, and one name you write.

Three of those concepts are special. The workspace's identifier, time, and location concepts are its anchors: they are what the planner joins on. Referring to them is how you get cross-source alignment without writing it.

That is the whole trade. You give up naming tables and join keys; you get a statement that does not change when the data is re-sourced, re-partitioned, or joined through a different intermediate table.

One question, four sources

"Monthly invoiced revenue and average support load per country, for two-year contracts." The data lives in four places: a billing export, a CRM account table, a helpdesk extract at a different grain, and a geography lookup.

In standard SQL you spell out every relationship:

SELECT date_trunc('month', b.invoice_date)  AS month,
       g.country                            AS country,
       SUM(b.invoice_total)                 AS invoiced,
       AVG(t.ticket_count)                  AS avg_tickets
FROM   billing_export b
JOIN   crm_accounts   c ON c.account_id = b.account_id
JOIN   geo_lookup     g ON g.postcode   = c.postcode
LEFT JOIN (
    SELECT account_id,
           date_trunc('month', opened_at) AS month,
           COUNT(*)                       AS ticket_count
    FROM   support_tickets
    GROUP  BY 1, 2
) t ON t.account_id = c.account_id
   AND t.month      = date_trunc('month', b.invoice_date)
WHERE  c.contract_type = 'Two year'
GROUP  BY 1, 2
ORDER  BY 1

In Anchor SQL you name the four things you want and how to bucket them:

Nothing is hidden. The identifier join, the grain reconciliation between billing and helpdesk, and the geo lookup all still happen — they came from the ontology instead of from you. The compiled plan comes back on every response (plan.chips, plan.joinPlan, plan.strategy), so you can read exactly which sources were joined, on what, and with how much fan-out.


Coming from SQL

Habits that do not transfer, and what to do instead.

Habit
Why it does not apply
Instead

FROM orders

There are no tables in the language. A concept may span several sources, and which ones are used is a planning decision

Omit FROM entirely

JOIN … ON a.id = b.id

Join paths come from the ontology's identifier and time concepts

Reference the concepts; the planner joins. Pin with ON "source" if it picks the wrong spine

orders.revenue, o.revenue

A qualifier names a table, and there is no table to name. "Orders"."Revenue" parses but then binds to nothing and fails with unknown_concept

"Revenue", unqualified. Disambiguate with FROM source:"…" or ON "…"

date_trunc('month', created_at)

Bucketing is a first-class operation on the time anchor, not a function over a named column

time(month)

GROUP BY 1, 2

Ordinals refer to output positions; the resolver binds by name

Repeat the projection: GROUP BY time(month), "Region"

ORDER BY 2 DESC

Same — an ordinal is not a name

Alias the projection and order by the alias, or repeat the aggregate

Re-deriving a KPI (SUM(rev) / COUNT(DISTINCT cust))

The workspace already has a definition; your reconstruction will drift from it

SHOW METRICS, then reference the metric by name

WHERE country = 'France' after joining a geo table

Location matching is an anchor operation over enriched geography

WHERE location = 'France' or WHERE location.country = 'FR'

SELECT *

The projection decides which sources join and which columns are read

Name the concepts you want

'single' vs "double" quotes used loosely

Double quotes are always a concept; single quotes are always a string literal. WHERE "Region" = "EMEA" looks for a concept called EMEA

WHERE "Region" = 'EMEA'


Keyword reference

Everything the dialect adds on top of ordinary SELECT syntax. Reserved words are written unquoted; concept names are always double-quoted.

Keyword
Meaning
Example
Replaces in standard SQL

"Concept Name"

A concept, by name. Matches the concept's name first, then its schema field name, case-insensitively

SELECT "Monthly Charges"

A qualified column reference — and the joins needed to reach it

entity

The workspace's identifier concept

SELECT entity, "Plan Tier"

The primary/foreign key column, and every JOIN … ON that uses it

time

The workspace's time concept

WHERE time >= '2025-01-01'

Naming one table's timestamp column

time_from

Inclusive start of the query's time window. Lifted out of WHERE into the plan's typed time range

WHERE time_from >= '2025-01-01'

WHERE created_at >= … repeated on every joined table

time_to

Inclusive end of the same window

WHERE time_to <= '2025-06-30'

WHERE created_at <= …, likewise

location

The workspace's location concept

WHERE location = 'France'

A join to a geography table plus a filter on it

location.<level>

Filter the location anchor at a named admin level: country, region, city, postal_code

WHERE location.country IN ('US', 'CA')

Filtering the geo table's country column

time(<grain>)

Bucket the time anchor

SELECT time(month)

date_trunc('month', t.ts)

time("Concept", <grain>)

Bucket a named time concept — required when the workspace has more than one

time("Vehicle Date", week)

date_trunc('week', v.vehicle_date)

location(<level>)

Bucket the location anchor to an admin level

SELECT location(country)

Join to a geo table, then GROUP BY geo.country

location("Concept", <level>)

Bucket a named location concept

location("Site Country", country)

The same, against a chosen column

<ref> ON "source"

Pin a concept, anchor, or grain to one source, making it the spine the plan is built around

entity ON "CRM Accounts"

Choosing which table drives the FROM clause and which are joined onto it

FROM source:"name"

Narrow name resolution to one source

FROM source:"Billing Export"

Nothing — it is not a table reference

FROM dataset:"name"

Narrow to one derived dataset

FROM dataset:"Cleaned Orders"

Nothing

FROM tag:"name"

Narrow to concepts carrying a tag

FROM tag:"finance"

Nothing

SHOW CONCEPTS [LIKE 'pattern']

List concepts, optionally filtered

SHOW CONCEPTS LIKE '%revenue%'

Querying information_schema.columns

SHOW METRICS

List the workspace's defined KPIs

SHOW METRICS

Reading a semantic-layer config by hand

SHOW SOURCES

List sources and datasets, with their identifiers, time columns, and join links

SHOW SOURCES

SHOW TABLES

DESCRIBE "name"

Detail on one concept, metric, source, or dataset

DESCRIBE "Churn Rate"

DESCRIBE <table>

SELECT, WHERE, GROUP BY, HAVING, ORDER BY, LIMIT, AS, AND/OR/NOT, IN, BETWEEN, IS NULL, SUM/AVG/COUNT/MIN/MAX and COUNT(DISTINCT …) all behave as you expect. LIKE/ILIKE behave as standard SQL.


The statement, clause by clause

Concepts by quoted name

One reference reaches every source the concept is mapped into. If two sources both carry "Revenue" as distinct concepts, the reference is ambiguous and the error lists both with the sources they come from — narrow it with a scope or a pin.

The anchors

entity, time, and location bind to the workspace's identifier, time, and location concepts. They are the only unquoted names the language accepts (besides projection aliases).

Standard SQL — the identifier is a column you name, and every join repeats it:

Anchor SQL — the identifier is entity, and the joins are implied by using it:

GROUP BY entity, time(month) gives panel shape: one row per entity per period.

If the workspace happens to contain a concept literally named time, entity, or location, the query still runs and a warning tells you the rule: unquoted resolves to the anchor, "time" quoted binds the concept.

When a workspace holds several identifier or time concepts, a bare anchor is ambiguous and the error lists the candidates. Only grains have a named form (time("Vehicle Date", week)); there is no entity("…") and no named time_from/time_to. The fix is to abandon the anchor and name the concept directly — "Contract Number" instead of entity, WHERE "Origination Date" >= '2020-01-01' instead of time_from — and the error's suggestedQuery carries exactly that rewrite.

Time windows

time_from and time_to are lifted out of the filter list into the plan's typed time range, which the planner then applies to every source it touches, at each source's own grain.

Standard SQL — the window is repeated per table, and getting it wrong on one silently drops rows:

Anchor SQL:

time_from accepts >= or =; time_to accepts <= or =; either end may be omitted for an open range. time BETWEEN 'a' AND 'b' is lifted the same way. Any other comparison on timetime >= '2025-01-01' on its own, for example — is a plain filter on the time concept rather than a plan-wide window.

Grains

A grain buckets an anchor axis. It is the replacement for date_trunc, and unlike date_trunc it also tells the planner what grain to align other sources to.

Standard SQL — bucket, and then re-bucket in the join condition so a monthly series meets a daily one:

Anchor SQL:

Valid time grains: nanosecond, microsecond, millisecond, second, minute, hour, day, week, month, quarter, year. Asking for a grain finer than the data's native sampling interval fails with grain_out_of_bounds and names the finest grain it can serve.

Valid location levels: country, region, city, postal_code.

When a workspace has more than one time (or location) concept, the bare form is ambiguous. Name the concept inside the reserved function:

"Vehicle Date"(week) is not valid syntax and is rejected outright — a quoted name that collides with a built-in function ("Month", "Left") would otherwise parse into something else entirely. Bare time(week) on a multi-time workspace raises ambiguous_concept, and the error's suggestedQuery rewrites every occurrence in the statement into the named form, so it is runnable as-is. SELECT, GROUP BY, and ORDER BY must all use the same form.

Location

Location matching runs against geo-enriched columns, not string equality on whatever the source happened to store.

Standard SQL — join a lookup, hope the spellings agree:

Anchor SQL:

WHERE location = 'France' filters at whatever admin level the location concept's own type implies. A single statement may filter at one admin level only.

If the location concept holds raw coordinates and geo enrichment has not run for that source, the statement fails with needs_enrichment — enrich the source, then retry the statement unchanged.

Metrics

A KPI defined in the workspace is a concept like any other, referenced by name, verbatim.

Standard SQL — restate the definition and hope it matches whatever the dashboard uses:

Anchor SQL:

Run SHOW METRICS before writing any aggregate — it lists each metric's unit, its definition rendered in concept names, and the anchors it can be broken down by. Three rules follow from a metric already being an aggregate:

  • It cannot be re-aggregated. SUM("Churn Rate") is rejected.

  • It cannot be a GROUP BY key (metric_not_groupable).

  • It filters in HAVING, not WHERE. Writing it in WHERE is routed to HAVING with a warning where the predicate can be split out; where it cannot, the statement fails with metric_in_where.

Pins

<reference> ON "source name" fixes which source a concept, anchor, or grain resolves against, and makes it the spine the rest of the plan is built around.

Any quoted concept, reserved word, or grain call can carry one, including inside an aggregate:

A pin anywhere in the statement is also a workspace-wide hint: when an unrelated name matches concepts in several sources, the one bound to a pinned source wins. Reach for a pin when the planner picks the wrong spine, or when a pin_no_edge / pin_unknown_source error tells you to.

Every join is left-outer from the spine: spine rows without a match survive with nulls, and rows that exist only in a joined source are dropped. That means the spine decides the population — pinning it to a smaller source silently shrinks every total, at a healthy-looking fan-out of 1.0x, because fan-out only measures inflation. When the chosen spine is smaller than a source it joins, the response carries a warning saying which rows are excluded.

Scope

FROM exists only to narrow name resolution. It never references a table, and most statements need none.

One scope per statement. FROM orders — a bare name with no qualifier — fails with unknown_scope and a suggestion rewriting it as FROM source:"orders".

Scope and pin do different jobs: scope shrinks the pool of names a reference may bind to; a pin decides which source a specific reference resolves against and which source anchors the plan.

Filters

WHERE filters rows. =, !=, >, >=, <, <=, BETWEEN, IN, LIKE/ILIKE, IS NULL, and AND/OR/NOT combinations all work:

LIKE uses standard SQL wildcard semantics — % matches any run, _ one character, the pattern is anchored, and matching is case-sensitive; ILIKE is its case-insensitive form. LIKE 'etail' carries no wildcard and matches only the exact string; write LIKE '%etail%' for a substring. Use IN (…) for exact membership.

HAVING filters aggregates and metrics. A plain column condition in HAVING fails with having_requires_aggregate and tells you to move it to WHERE; the reverse is corrected automatically where it can be.

Aggregates and expressions

SUM, AVG, COUNT, COUNT(*), COUNT(DISTINCT …), MIN, MAX. Arithmetic between aggregates, CASE, CAST, and scalar functions are all allowed in a projection:

DISTINCT is valid only as a whole projection — COUNT(DISTINCT "Customer ID") is fine, SUM("Revenue") / COUNT(DISTINCT "Customer ID") is not, and fails with unsupported_syntax. Split it into two projections and divide client-side, or define it once as a metric.

ORDER BY takes a projection alias, a concept, a grain, or an aggregate the statement already selects. An aggregate that is not selected fails with a suggestedQuery that adds it to the projection. Ordinals (ORDER BY 2) are not names and do not resolve.

Output column names are derived when you do not supply one: a plain reference keeps the concept's name, an aggregate becomes <function>_<concept>, and a grain becomes <field>__<level>. Alias explicitly with AS whenever the result is consumed programmatically — a derived name is a function of workspace metadata and can move under you.


What is not supported

Each of these is refused at compile time with unsupported_syntax (or unknown_scope), never silently reinterpreted.

Construct
Why
What to do instead

FROM <table>

There is no table namespace to resolve against; a concept's storage is a planning decision

Omit FROM. Use FROM source:/dataset:/tag: only to disambiguate a name

JOIN

Join paths are derived from the ontology's identifier and time concepts. A hand-written join would be a second, contradictory source of truth

Name the concepts. If the spine is wrong, pin it with ON "source"

Subqueries and CTEs

A statement compiles to a single planned pipeline, not a nestable relational algebra

Run two statements, or materialise the inner result as a dataset and query the concepts it produces

UNION / EXCEPT / INTERSECT

Set operations need two independent relations, which the single-pipeline model has no place for

Build a dataset that stacks the sources, then query it

SELECT *

The projection is what tells the planner which sources must join and which columns to read; a star has no answer

Name the concepts, or SHOW CONCEPTS first

SELECT DISTINCT

There is no row-level dedup step in the compiled pipeline

GROUP BY the concepts you want distinct, or COUNT(DISTINCT …)

DISTINCT nested in an expression

The engine can express one distinct projection, not a distinct sub-term of a larger one

Project COUNT(DISTINCT …) on its own and combine the columns downstream

Window functions (OVER (…))

A window sits outside the expression set the projection compiler accepts

Aggregate at the grain you need and window client-side, or build a dataset

OFFSET

Paging is key-based, not offset-based

Page with startKey / nextStartKey

Multiple statements in one request

One statement, one plan, one result union

Send them separately

GROUP BY 1 / ORDER BY 2

Ordinals index output positions; the resolver binds by name

Repeat the projection, or order by its alias

Table qualifiers (o."Revenue")

A qualifier names a table. Parsed, it becomes a concept named o.Revenue, which does not exist

Use the bare concept name

INSERT / UPDATE / DELETE / DDL

The query surface is read-only

Use the REST datasets and ontology endpoints


Metadata commands

Discovery happens in-dialect, and the names these return are the exact tokens you quote in the next statement — there is no id round-trip.

Command
Returns
Columns

SHOW CONCEPTS

Every concept in the workspace

name, kind, classification, type, unit, sources, grain, tags

SHOW CONCEPTS LIKE '%revenue%'

Concepts matching a pattern

as above

SHOW METRICS

Defined KPIs, referencable by name

name, unit, definition, anchors

SHOW SOURCES

Sources and derived datasets in scope

name, kind, rows, columns, identifiers, time, joins

DESCRIBE "Monthly Revenue"

One concept, metric, source, or dataset in detail

property, value

LIKE uses SQL wildcard semantics (% any run, _ one character), matches case-insensitively, and matches the whole name — LIKE 'revenue' does not match "Monthly Revenue", LIKE '%revenue%' does.

classification on a concept row is what makes the anchors work: identifier concepts answer to entity, time to time/time(…), location to location/location(…). The joins column on SHOW SOURCES is the shortest way to see which sources actually connect before writing a cross-source statement.

DESCRIBE requires a quoted name; a bare DESCRIBE Revenue fails with a parse_error carrying the quoted form as its suggestion.

Metadata commands come back as a metadata response: tabular rows, no plan.


The REST endpoint

POST /api/v1/workspaces/{wsId}/ontology/query — requires the ontology:read scope.

Request body:

Field
Type
Description

anchorSql

string, required

The statement — a single SELECT, or a SHOW/DESCRIBE command

limit

integer, optional

Rows per page, 1–10 000 (default 1000)

startKey

integer, optional

Resume paging from a previous response's nextStartKey

validateOnly

boolean, optional

Compile and plan without executing; returns the validated arm

projectionMode

related or minimal, optional

related (default) adds ontology-linked context; minimal returns only selected concepts and anchors

There is no transport-level total row cap. limit controls one response page; continue with nextStartKey until it is absent. Keep anchorSql and projectionMode unchanged across pages. An SQL LIMIT is different: it caps the statement's total result and therefore the final page.

The endpoint always responds 200 for a statement that reached the engine; the body is a union under data, discriminated by ok and then by kind:

kind

Meaning

rows

A materialised result: rows, columns, schemaEntries, per-column units, the compiled plan, warnings, and nextStartKey when there is another page

metadata

SHOW/DESCRIBE output: command, columns, rows

validated

A validateOnly pass: sql, plan, and warnings, without execution

Results are not persisted — dataViewId is null on this route. To keep one, create a dataset via POST /api/v1/workspaces/{wsId}/datasets.

Reading the plan

plan is how the statement explains itself, and it is what validateOnly exists to give you cheaply:

  • chips — an ordered list of {kind, label, detail}, where kind is one of scope, spine, join, filter, grain, aggregate, metric, projection, warning. Every filter appears, including the time window (filter / time window with the resolved bounds), so the plan states not just how the sources were joined but what cut the rows. This is the human-readable summary of what the planner decided.

  • joinPlan — one {sourceDatasetId, targetDatasetId, conceptName, strategy, fanout} per join actually performed. strategy is identifier, time_exact, time_granularity, time_asof, or concat; fanout is the row multiplication that join caused, and is the first thing to look at when a SUM comes back larger than expected.

  • strategy — the shape of the whole plan: single (one source, no join), star, identifier_chain, ts_chain, or concat.

Dry-run anything expensive before executing it:

Paging

A rows response with more data carries nextStartKey. Pass it back as startKey with the same statement to fetch the next page; the last page has no nextStartKey. There is no OFFSET.


The error model

A statement the engine refuses is still a 200 — the union member with ok: false:

Every error is structured for a one-round repair. It carries either candidates (ranked near-misses — what you probably meant, each tagged concept, metric, derived, source, dataset, tag, or grain) or a suggestedQuery (the corrected statement, ready to run), plus a span — half-open character offsets [start, end) into your original statement text, so an editor can highlight the offending fragment or splice a fix in place. perSource explains join failures dataset by dataset.

Code
Meaning
Fix

parse_error

The statement did not parse

Follow the message; span points at the fragment

unsupported_syntax

Valid SQL, but outside the dialect (JOIN, subquery, UNION, window, SELECT *, …)

unknown_concept

No concept, metric, source, or dataset by that name — or an unquoted word that is neither reserved nor an alias

Take the top candidate; check the name is double-quoted

ambiguous_concept

The name, or a bare time(…)/location(…), matches more than one concept

Run the suggestedQuery; or scope with FROM source:"…", or pin with ON "…"

unknown_scope

FROM had no qualifier, or names nothing in the workspace

Use source:/dataset:/tag:; SHOW SOURCES to check the name

reserved_word_misuse

entity/time/location used where it cannot go, or a grain named a concept of the wrong classification

Follow the message; classify the concept, or name a valid one

metric_in_where

A metric sits inside a compound WHERE predicate that cannot be split

Restate it as its own HAVING condition

having_requires_aggregate

A plain column condition in HAVING

Move it to WHERE

grain_out_of_bounds

The data cannot support a grain that fine

Use the suggested coarser grain

metric_not_groupable

A metric used as a GROUP BY key

Group by a concept or grain instead

no_join_path

No join path links the referenced sources — no shared identifier, and not every source has a time column

perSource explains each side; the sources may simply not connect

pin_no_edge

The pinned source has no join edge into the rest of the plan, or the concept is not bound to it

Pin a source the concept is actually bound to

pin_unknown_source

ON "…" names no source in scope

Fix the name; SHOW SOURCES lists them

empty_projection

Nothing to select

Add at least one projection

needs_enrichment

A location concept holds raw coordinates with no geo sidecar

Run location enrichment on that source, then retry unchanged

enrichment_running

Geo enrichment for that location concept is already running; enrichmentProgress carries its live status

Retry the statement unchanged once enrichment lands

execution_error

Compiled fine, failed at runtime

Follow the message

translation_failed

Only from the natural-language endpoint: the question could not be turned into a statement

Retry, or write the Anchor SQL yourself

The intended loop: apply the fix and retry once. If that retry fails, use SHOW CONCEPTS, SHOW SOURCES, or DESCRIBE and copy an exact returned token rather than guessing another name. Re-sending the same statement against the same error is never productive.

Treat the code list as open — handle unknown codes by surfacing message rather than failing.


Worked examples

Entity 360 — everything about the entities matching a filter, with the planner joining whatever sources hold those concepts:

Time-aligned two-source compare — two series from two sources at different native grains, aligned to daily; no join written:

Location and time rollup:

KPI by segment — the workspace's own definition, by name:

Filtered aggregate with HAVING and a stable sort key:

Named grain on a multi-date workspace:

Pinned spine — force the account list to drive the plan rather than the billing export:

Panel shape — one row per entity per period, the input a temporal twin expects:


Next steps

Last updated