PassQL

PassQL is the textual query language of Synex Lab. You write it in the Lab workspace or submit its AST to the Lab API; it compiles to the same AST the visual Blocks builder produces and runs against the Lab read model (Postgres) — never against the passport store directly. That's why queries are metered as Postgres-only · 0 RCU.

A query is a pipeline: one from clause followed by stages, each introduced by a pipe (|).

A full pipeline

from ontology:"Battery X" depth:C status:released
| where p"Weight" > 10 and (p"Material" in ["steel", "aluminium"] or p"Recycled" = true)
| where p"Dimensions".width <= 40
| group by p"Material"
| agg avg(p"Weight") as avg_weight, count() as n
| sort avg_weight desc
| limit 100

Syntax rules

  • Keywords and property names are case-insensitive (WHERE ≡ where).
  • Strings use double quotes with backslash escapes: "Fo\"o", "a\\b", \n, \t.
  • # starts a comment that runs to the end of the line.
  • Whitespace is insignificant — the multi-line layout is convention, not requirement.
  • Errors carry the exact byte offset of the offending token; the editor underlines the span and lists what was expected.

from — the source

Selects which passports the pipeline reads. ontology: is required and repeatable (or comma-separated).

from ontology:"Battery X" ontology:"Chargers" depth:C,B status:released,draft
KeyMeaning
ontology:"…"Required, repeatable. The ontology name or its id (ontology:"ontology_0218…"). Names resolve against your team's catalog; an ambiguous name asks for the id.
depth:COptional. Restrict to ontology depth letters (depth:C,B for several).
lang:"de"Optional. The language every unqualified property reference resolves in — see Languages. Omitted, each ontology's own default language applies. One of the 24 official EU language codes.
status:releasedOptional. Filter on the explicit publish status (released, draft, archived). Passports whose status is only inherited are not matched — see resolved status.

Only ontologies belonging to your team resolve; anything else fails at compile time.

Property references

Properties are the typed fields defined on your ontologies.

FormMeaning
p"Weight"By name. If several selected ontologies define that name with the same type, the reference matches all of them — cross-ontology queries just work.
p:id"property_9f2k…"Pinned to one property id. Required when the same name has conflicting types across ontologies; the error lists the candidate ids to pin.
p"Description"@"de"Pinned to one language, overriding lang: for this reference alone. Works on either form above.

Languages

short_text, long_text, tags, and file hold one value per language. A reference resolves against exactly one of them, chosen narrowest-first:

  1. the reference's own @"de",
  2. the pipeline's lang:"de",
  3. the ontology's default language.

There is no fallback inside a query. A passport with no French name doesn't match p"Name"@"fr" exists — which is what makes translation-completeness queries possible:

# Everything still missing a French name
from ontology:"Battery X"
| where p"Name"@"fr" is_empty

Because exactly one language is matched, a property translated six times still contributes one row: count() counts passports, never translations. A code outside the 24 official EU languages is a parse error.

lang: and @ apply to every reference, not only the translatable types. A number, date, boolean or any other untranslated value is held once, under the ontology's default language, so under a lang: other than the default it reads as empty and avg() over it returns null. Pin such references back to the default language — from ontology:"Battery X" lang:"fr" | where p"Weight"@"en" > 10 — or leave lang: off and pin the text references instead.

Operators by type

Each property type supports a specific set of operators:

TypeOperators
short_text, long_text= != contains starts_with in exists is_empty
number, measurement= != > >= < <= between in exists is_empty
range> >= < <= between (on the range start) exists is_empty
boolean= != exists is_empty
date= != > >= < <= between exists is_empty
tagscontains (has this tag) in (has any of) exists is_empty
file, imageexists is_empty only
compositeall operators; typed by the evaluated result

| where — filtering

| where p"Weight" > 10 and not p"Origin" = "CN"
| where p"Material" in ["steel", "aluminium"] or p"Tags" contains "recycled"

Boolean logic

and, or, not, and parentheses, with the usual precedence (not before and before or). Multiple where stages are AND-ed together — split long filters across stages to keep them readable.

Operators

OperatorExampleNotes
= / !=p"Material" = "steel"equality / inequality
> >= < <=p"Weight" >= 2.5numbers, measurements, dates, ranges
betweenp"Weight" between 1 and 5inclusive bounds
inp"Material" in ["steel", "wood"]any of; max 100 values
containsp"Notes" contains "recall"case-insensitive substring; on tags: has this tag
starts_withp"SKU" starts_with "EU-"case-insensitive prefix
existsp"Weight" existshas a non-empty value
is_emptyp"Weight" is_emptymissing, null, "" or []

Values are numbers (10, -2.5), strings ("steel"), or booleans (true / false). Dates are written as strings ("2026-01-01") and parsed server-side.

JSON paths

Nested values (typically composites) are addressed with dot paths. Numeric comparisons only match values that are actually numeric in the JSON.

| where p"Dimensions".width <= 40

Publish-status conditions

Two status dimensions can be filtered anywhere a property condition can, and compose with and / or / not:

| where publish_status != "released"           # explicit status (null-aware)
| where resolved_status = "draft"              # effective/inherited status
DimensionSemanticsOperators
publish_statusThe explicitly set status. != is null-aware: passports with no explicit status count as "not X".= != in exists is_empty
resolved_statusThe effective status after inheritance: the explicit value wins, otherwise the nearest ancestor's — except released, which never travels, so a passport with no status of its own under a released ancestor resolves to draft. Default draft, never empty.= != in

resolved_status is computed live by walking the parent chain (≤25 hops), so it's never stale but heavier than other filters — prefer status: or publish_status when explicit status is what you mean.

| select — projection

Row queries project a mix of dimensions and properties. Without a select, the default projection is snowflake, friendly_id, tppmp.

| select friendly_id, tppmp, p"Weight", p"Tags"

Available dimensions: snowflake, friendly_id, tppmp, depth, ontology_id, publish_status, created_at, updated_at. Property values come back in their raw shape (numbers as numbers, tags as lists), translatable ones in the language the reference resolves to.

| group by + | agg — analytics

| group by p"Material"
| agg avg(p"Weight") as avg_weight, count() as n
  • Group keys: groupable properties (short_text, long_text, number, boolean, date, measurement, composite) or dimensions.
  • Aggregates: count(), sum(p"X"), avg(p"X"), min(p"X"), max(p"X"), distinct_count(p"X"). sum / avg / min / max need numeric-ish properties. Every aggregate needs an alias (as name).
  • agg without group by computes whole-result aggregates (one row).
  • Grouped results are capped at 1000 groups (the Chart tab, at 50) — beyond that you get a typed error asking you to narrow.

| sort and | limit

| sort avg_weight desc, n
| limit 200
  • Row queries sort by one dimension or property (ties broken by snowflake) and paginate by keyset cursor — pages stay stable even as data changes. limit is the page size, 1–500 (default 100).
  • Grouped queries sort by aggregate aliases or group keys (up to two terms).
  • Direction is asc (default) or desc; empty values sort last.

| set — transforms (bulk writes)

Appending a set stage turns the query into a transform: from + where select the passports, set describes the writes. Transforms are the only Lab operation that writes data, so they need the lab:write ability — plus releases:write when the transform assigns publish_status — and a mandatory preview; the most recent one can be rolled back.

from ontology:"Battery X" | where p"Weight" exists
| set p"Weight" = p"Weight" * 2.20462,
      p"Origin" = concat("EU-", p"Plant Code"),
      publish_status = "draft"

Targets — writable property types (short_text, long_text, number, boolean, date, measurement) and publish_status (a quoted status, or null to clear it so the passport inherits again; setting a status pins it). "released" follows the release rules below.

Expressions

ElementExample
Literals2.20462, "EU", true
Property valuesp"Weight" — the passport's live value at apply time
Arithmetic+ - * / with parentheses
Concatenationconcat("EU-", p"Code") — nulls become ""
Castingcast(p"Code", "number") — types "number", "text", "boolean"

Values are recomputed from live passport data when the transform runs — the read model only steers selection. A passport whose expression fails (non-numeric operand, division by zero, cast failure, type mismatch) is skipped and reported; the run always finishes. Up to 16 assignments per transform.

Releasing with a transform

set publish_status = "released" follows the same rules as a direct release:

  • Only what you select. A transform releases the passports its from and where select, and nothing beneath them — there is no "and its children" here.
  • Ancestors first, in separate transforms. A selected passport whose ancestor isn't released is refused and reported as a conflict naming that ancestor; the rest of the run continues. Release top-down — the models, then the batches, then the cells — one transform per level: a run is processed in chunks by different workers in no guaranteed parent-first order, so selecting a whole chain at once can attempt a cell before its batch.
  • Numbered and locked. Each release stamps the next release_number. A released passport's values are frozen, so a transform that writes a property of one is refused for that passport. Values and the release in the same transform are fine: the values are written first.
  • Taking it back. set publish_status = "draft" (or null) on a released passport un-releases it, and is refused while anything below it is still released; at the item tier's core level it cancels the billed release. "archived" on a released passport is refused outright.
  • Rolling back a transform undoes its releases the same way, and restores a release it removed as a new numbered release. A passport the rules no longer allow it for is reported as a conflict and left alone.

Limits

CapValue
Filter nesting / conditions8 levels / 64 conditions
in list100 values
Page size500 rows
Groups1000 (chart: 50)
Aggregates / group keys / sort terms16 / 8 / 2
set assignments16
Statement timeout10 s → typed error suggesting narrowing or an export

Results carry a watermark ("data as of …"): the read model syncs from the passport store within seconds of a write, and repeated identical queries are served from a cache that auto-invalidates on any team write. Full result sets are downloaded via exports (CSV/XLSX from the Run history).

Recipes

Common queries

# Heaviest materials, released stock only
from ontology:"Products" status:released
| group by p"Material" | agg avg(p"Weight") as w, count() as n | sort w desc

# Everything that is NOT effectively released (incl. inherited/unset)
from ontology:"Products" | where resolved_status != "released"

# Data quality: missing critical fields
from ontology:"Products" | where p"Weight" is_empty or p"Datasheet" is_empty

# Bulk release a batch's series instances (the model above them must be released already)
from ontology:"Products" depth:B | where p"Batch No" = "B-2026-07"
| set publish_status = "released"

# Unit migration with a provenance note
from ontology:"Products" | where p"Weight (lbs)" exists
| set p"Weight" = p"Weight (lbs)" / 2.20462, p"Notes" = concat(p"Notes", " [kg-migrated]")

Ready to run these? Head to Synex Lab for the query, transform, and rollback endpoints.

Was this page helpful?