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 1In 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.
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.
"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 time — time >= '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 BYkey (metric_not_groupable).It filters in
HAVING, notWHERE. Writing it inWHEREis routed toHAVINGwith a warning where the predicate can be split out; where it cannot, the statement fails withmetric_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.
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.
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:
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}, wherekindis one ofscope,spine,join,filter,grain,aggregate,metric,projection,warning. Every filter appears, including the time window (filter/time windowwith 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.strategyisidentifier,time_exact,time_granularity,time_asof, orconcat;fanoutis the row multiplication that join caused, and is the first thing to look at when aSUMcomes back larger than expected.strategy— the shape of the whole plan:single(one source, no join),star,identifier_chain,ts_chain, orconcat.
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.
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

