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:published
| 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:published,draft
| Key | Meaning |
|---|---|
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:C | Optional. Restrict to ontology depth letters (depth:C,B for several). |
status:published | Optional. Filter on the explicit publish status (published, 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.
| Form | Meaning |
|---|---|
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. |
Each property type supports a specific set of operators:
| Type | Operators |
|---|---|
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 |
tags | contains (has this tag) in (has any of) exists is_empty |
file, image, chart | exists is_empty only |
composite | all 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
| Operator | Example | Notes |
|---|---|---|
= / != | p"Material" = "steel" | equality / inequality |
> >= < <= | p"Weight" >= 2.5 | numbers, measurements, dates, ranges |
between | p"Weight" between 1 and 5 | inclusive bounds |
in | p"Material" in ["steel", "wood"] | any of; max 100 values |
contains | p"Notes" contains "recall" | case-insensitive substring; on tags: has this tag |
starts_with | p"SKU" starts_with "EU-" | case-insensitive prefix |
exists | p"Weight" exists | has a non-empty value |
is_empty | p"Weight" is_empty | missing, 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 != "published" # explicit status (null-aware)
| where resolved_status = "draft" # effective/inherited status
| Dimension | Semantics | Operators |
|---|---|---|
publish_status | The explicitly set status. != is null-aware: passports with no explicit status count as "not X". | = != in exists is_empty |
resolved_status | The effective status after inheritance, with the series-gate rule applied; 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).
| 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/maxneed numeric-ish properties. Every aggregate needs an alias (as name). aggwithoutgroup bycomputes 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.
limitis the page size,1–500(default100). - Grouped queries sort by aggregate aliases or group keys (up to two terms).
- Direction is
asc(default) ordesc; 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 require the admin/owner role, a mandatory preview, and 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).
Expressions
| Element | Example |
|---|---|
| Literals | 2.20462, "EU", true |
| Property values | p"Weight" — the passport's live value at apply time |
| Arithmetic | + - * / with parentheses |
| Concatenation | concat("EU-", p"Code") — nulls become "" |
| Casting | cast(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.
Limits
| Cap | Value |
|---|---|
| Filter nesting / conditions | 8 levels / 64 conditions |
in list | 100 values |
| Page size | 500 rows |
| Groups | 1000 (chart: 50) |
| Aggregates / group keys / sort terms | 16 / 8 / 2 |
set assignments | 16 |
| Statement timeout | 10 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, published stock only
from ontology:"Products" status:published
| group by p"Material" | agg avg(p"Weight") as w, count() as n | sort w desc
# Everything that is NOT effectively published (incl. inherited/unset)
from ontology:"Products" | where resolved_status != "published"
# 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
from ontology:"Products" depth:B | where p"Batch No" = "B-2026-07"
| set publish_status = "published"
# 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.