SPARQL Query Engine
SAL answers SPARQL by translating it to SQL, not by loading the graph into a triplestore.
A SELECT query is parsed, rewritten into a DuckDB statement over the triples view of the built Iceberg table, and executed there.
This keeps queries running directly against the data product on disk or in object storage, with no separate database to load or keep in sync. The cost is that only the subset of SPARQL that has a direct relational translation is supported; everything else is reported as an error rather than silently answered wrong. Supported syntax is the exact list.
Where you can run a query
Section titled “Where you can run a query”| Surface | Notes |
|---|---|
sal query --sparql |
Interactive shell. Ctrl + R runs, F2 shows the translated SQL, Ctrl + H lists the other keys, Ctrl + D quits. |
sal serve |
SPARQL Protocol endpoint at /sparql, answering application/sparql-results+json. |
sal serve --with-ui |
The SPARQL tab, a YASGUI editor pointed at that same local endpoint. |
All three go through the same translator, so a query that works in one works in all of them.
The shell caps a query that declares no LIMIT at 100 rows so an exploratory query cannot print the whole table; the HTTP endpoint applies no implicit limit.
How it works
Section titled “How it works”flowchart LR A["SPARQL SELECT"] --> B["goRDFlib parser"] B --> C["Triple patterns<br/>and FILTERs"] C --> D["SQL translator"] D --> E["DuckDB<br/>triples view"] E --> F["Iceberg table<br/>.sal/data"] E --> G["Rows"] G --> H["Shell table or<br/>SPARQL JSON"]
- Parse. The query text is parsed by
goRDFlib’s SPARQL parser. A query that is not aSELECT, or that uses a solution modifier the translator cannot express, is rejected here. - Flatten the pattern. The
WHEREclause is walked and reduced to a flat list of triple patterns plus a list ofFILTERexpressions. A group pattern that is neither, such asOPTIONALorUNION, ends the walk with an error. - Translate. Each triple pattern becomes one self-join of the
triplesview; constants becomeWHEREequalities and shared variables become join conditions. This is described in The translation below. - Execute. DuckDB runs the statement against the Iceberg table through the
triplesview, reading the current snapshot. DuckDB is linked into thesalbinary, so no external database orduckdbCLI is involved. - Render. Every column is rendered to text by DuckDB itself, then printed as a table by the shell or encoded as SPARQL JSON by the endpoint.
The DuckDB handle, its extensions, and the triples view are opened once per process and reused, so sal serve pays that cost on the first request rather than on every one.
The translation
Section titled “The translation”Every triple pattern becomes one aliased scan of the triples view, joined to the others:
- A constant in subject, predicate, or object position becomes an equality against that alias’s column.
- The first occurrence of a variable binds to that alias and column.
- A repeated variable becomes a join condition equating its first binding with the new position, which is what makes a multi-pattern query a join rather than a cross product.
- The projection selects each variable’s bound column under its own name.
SELECT *projects every variable in the order it was first seen.
So this query:
PREFIX schema: <https://schema.org/>
SELECT ?s ?ageWHERE { ?s schema:name "bob" . ?s schema:age ?age .}becomes:
SELECT t0.subject AS "s", t1.object AS "age"FROM triples AS t0CROSS JOIN triples AS t1WHERE t0.predicate = 'https://schema.org/name' AND t0.object = 'bob' AND t0.subject = t1.subject AND t1.predicate = 'https://schema.org/age'The CROSS JOIN is not a cartesian product in practice: the shared ?s contributes t0.subject = t1.subject to the WHERE clause, which DuckDB plans as an inner join.
Press F2 in sal query --sparql to see the SQL generated for whatever is in the editor.
Object columns
Section titled “Object columns”The table splits objects across object_iri, object_string, object_geometry, object_byte, object_integer, object_float, and object_time (see Data Layout), and the translator picks the column from the term an object is being compared against:
| Term in the query | Column used |
|---|---|
| An IRI or prefixed name | object_iri |
| A bare number, or a literal typed with a numeric XSD datatype | COALESCE over object_float, object_integer, and object_byte |
An xsd:dateTime literal with an explicit timezone |
object_time, compared as a TIMESTAMP normalized to UTC |
| Any other literal | object_string |
| A geometry, in a GeoSPARQL function | object_geometry |
A projected object variable is read back as a COALESCE over every object column rendered as text — object_iri, the numeric columns cast to VARCHAR, object_time rendered back to its ISO form, object_string, and ST_AsText(object_geometry) — so a result row shows the object whichever column holds it. A triple whose object is a GeoSPARQL WKT literal stores its geometry in object_geometry and leaves the other object columns null; projecting it renders the geometry back to WKT, such as POINT (-89.5 40.5). The ST_AsText is what makes projecting an object load DuckDB’s spatial extension the first time; a join or a comparison on an object variable never renders the geometry and so never needs it.
In a FILTER, the same rule applies to whichever side the variable is compared against, so FILTER(?age > 21) compares the numeric columns while FILTER(?name = "bob") compares object_string. Note that a quoted number like "42" is an xsd:string and compares against object_string, matching where build stores it.
Supported syntax
Section titled “Supported syntax”Query forms
Section titled “Query forms”| Keyword | Supported | Notes |
|---|---|---|
SELECT |
Yes | The only supported query form. |
SELECT * |
Yes | Projects every variable bound by a triple pattern. |
DISTINCT |
Yes | Becomes SELECT DISTINCT. |
PREFIX |
Yes | Prefixed names are expanded before translation; an undeclared prefix is an error. |
WHERE |
Yes | Must contain at least one triple pattern. |
FILTER |
Partly | Comparisons and boolean combinations only — see Filters. |
LIMIT |
Yes | Becomes LIMIT. |
ASK, CONSTRUCT, DESCRIBE |
No | only read-only SPARQL SELECT queries are supported. |
OFFSET, ORDER BY, GROUP BY, HAVING |
No | SPARQL projection expressions and solution modifiers are not supported yet. |
Aggregates and projection expressions, e.g. (COUNT(?s) AS ?n) |
No | Same error. Count in SQL instead: SELECT count(*) FROM triples. |
OPTIONAL, UNION, MINUS, GRAPH, BIND, VALUES, subqueries |
No | only basic SPARQL triple patterns and FILTER expressions are supported yet. |
Property paths, e.g. schema:knows/schema:name |
No | SPARQL property paths are not supported yet. |
REDUCED |
Ignored | Accepted, but results are not deduplicated. Use DISTINCT if you need that. |
Everything the engine supports is read-only. There is no SPARQL Update surface at all; the way to change a data product is to edit its RDF source files and run sal build again.
Triple patterns
Section titled “Triple patterns”| Construct | Supported | Notes |
|---|---|---|
Variables, ?s or $s |
Yes | Both sigils work, in any of the three positions. |
Full IRIs, <https://example.org/a> |
Yes | Used verbatim. |
Prefixed names, schema:name |
Yes | Expanded from the query’s PREFIX declarations. |
The a keyword |
Yes | Expands to rdf:type. |
Predicate-object lists, ?s p1 ?a ; p2 ?b |
Yes | Expanded by the parser into separate patterns. |
| Plain and typed literals | Yes | Compared by lexical form; see the note below. |
Language-tagged literals, "hello"@en |
Partly | The tag is dropped and only "hello" is matched. |
Blank nodes, _:b |
No | Reported as an unknown prefix _. |
BASE and relative IRIs |
No | BASE parses but is not applied; a relative IRI is matched literally, not resolved. |
A datatype is used only to decide which object column to compare against, never matched itself. "5"^^xsd:integer, "5"^^xsd:double, and a bare 5 all become the same comparison, a COALESCE over the numeric columns cast to DOUBLE, so a value matches however it was typed. An xsd:dateTime with an explicit timezone compares against object_time as a TIMESTAMP. A literal stored as a string — an xsd:string (quoted "42" included), a zoneless dateTime, or a datatype without a typed column such as "2026-06-02"^^xsd:date — is compared as a string.
Filters
Section titled “Filters”FILTER supports comparisons between a variable and a constant, or between two constants, combined with && and ||:
| Operator | Supported | Notes |
|---|---|---|
=, !=, <, >, <=, >= |
Yes | |
&&, ||, ! |
Yes | |
GeoSPARQL geof: functions |
Partly | The Simple Features relations, geof:distance, and the geometry constructors; see GeoSPARQL. |
Arithmetic, IN, EXISTS |
No | |
Functions such as REGEX, STR, LANG, BOUND |
No |
Several FILTERs in one group are all applied, as if joined by &&.
A FILTER over a variable no triple pattern binds is an error rather than a query that matches nothing.
PREFIX schema: <https://schema.org/>
SELECT ?sWHERE { ?s schema:age ?age . FILTER(?age >= 21 && ?age < 65)}SELECT t0.subject AS "s"FROM triples AS t0WHERE t0.predicate = 'https://schema.org/age' AND (COALESCE(t0.object_float, CAST(t0.object_integer AS DOUBLE), CAST(t0.object_byte AS DOUBLE)) >= 21 AND COALESCE(t0.object_float, CAST(t0.object_integer AS DOUBLE), CAST(t0.object_byte AS DOUBLE)) < 65)GeoSPARQL
Section titled “GeoSPARQL”GeoSPARQL is the OGC standard for geospatial RDF. It has two halves, and SAL supports a subset of each:
- The vocabulary,
geo:(http://www.opengis.net/ont/geosparql#), is how geometry is written into the data: a feature has ageo:hasGeometry, and that geometry carries its shape as ageo:asWKTliteral typedgeo:wktLiteral. This is plain RDF, andsal buildhandles it like any other data. - The functions,
geof:(http://www.opengis.net/def/function/geosparql/), are how geometry is queried: topological relations such asgeof:sfIntersects,geof:distance, and geometry constructors such asgeof:envelope. These are what the translator maps to DuckDB’s spatial extension.
The mapping is deliberately mechanical. Each supported geof: function becomes the ST_ function of the same meaning, and there is no geometry logic in SAL itself: what DuckDB’s spatial extension computes is what the query answers. A function with no direct analog is an error rather than an approximation.
How geometry is stored
Section titled “How geometry is stored”A geo:wktLiteral object is parsed at build time and stored in the table’s object_geometry column as a native Iceberg geometry, leaving the other object columns null for that row (see Data Layout). DuckDB reads that column as GEOMETRY, so a geof: function applies to it directly with no parsing at query time.
Coordinates are stored as written, in longitude/latitude order, and the table declares them as CRS84. Any CRS IRI that led a literal, such as <http://www.opengis.net/def/crs/OGC/1.3/CRS84> POINT(-89.5 40.5), is dropped on the way in, and a literal in a query is treated the same way; everything is assumed to be in the same coordinate system, and nothing is reprojected.
@prefix schema: <https://schema.org/> .@prefix geo: <http://www.opengis.net/ont/geosparql#> .@prefix sf: <http://www.opengis.net/ont/sf#> .
<MyPoint> a schema:Place ; schema:name "Demo Point" ; geo:hasGeometry [ a sf:Point ; geo:asWKT "POINT(-89.5 40.5)"^^geo:wktLiteral ] .That data becomes three kinds of row: <MyPoint> geo:hasGeometry _:b with the blank node in object_string, _:b rdf:type sf:Point in object_iri, and _:b geo:asWKT ... with the point in object_geometry.
Function mapping
Section titled “Function mapping”geof: functions are accepted inside FILTER. The supported ones and what each becomes:
| GeoSPARQL | DuckDB spatial | Result | Notes |
|---|---|---|---|
geof:sfEquals(a, b) |
ST_Equals(a, b) |
boolean | |
geof:sfDisjoint(a, b) |
ST_Disjoint(a, b) |
boolean | |
geof:sfIntersects(a, b) |
ST_Intersects(a, b) |
boolean | |
geof:sfTouches(a, b) |
ST_Touches(a, b) |
boolean | |
geof:sfCrosses(a, b) |
ST_Crosses(a, b) |
boolean | |
geof:sfWithin(a, b) |
ST_Within(a, b) |
boolean | |
geof:sfContains(a, b) |
ST_Contains(a, b) |
boolean | |
geof:sfOverlaps(a, b) |
ST_Overlaps(a, b) |
boolean | |
geof:distance(a, b) |
ST_Distance(a, b) |
number | In the units of the coordinates, which is degrees. |
geof:distance(a, b, uom:degree) |
ST_Distance(a, b) |
number | Same as above; uom: is http://www.opengis.net/def/uom/OGC/1.0/. |
geof:distance(a, b, uom:metre) |
ST_Distance_Sphere(a, b) |
number | Great-circle metres. DuckDB computes this between points only. |
geof:envelope(a) |
ST_Envelope(a) |
geometry | |
geof:boundary(a) |
ST_Boundary(a) |
geometry | |
geof:convexHull(a) |
ST_ConvexHull(a) |
geometry | |
geof:intersection(a, b) |
ST_Intersection(a, b) |
geometry | |
geof:union(a, b) |
ST_Union(a, b) |
geometry | |
geof:difference(a, b) |
ST_Difference(a, b) |
geometry | |
geof:symDifference(a, b) |
ST_SymDifference(a, b) |
geometry |
Not supported: geof:buffer (it takes a unit SAL would have to convert), geof:getSRID, geof:relate, and the Egenhofer geof:eh* and RCC8 geof:rcc8* relation families. Calling one reports GeoSPARQL function geof:... is not supported yet.
How a function may be used follows from what it returns:
- A boolean function stands on its own in the
FILTER, and can be negated with!or combined with&&and||like any other condition. - A number, which is only
geof:distance, must be compared against a number with<,<=,>,>=,=, or!=. - A geometry function cannot be projected or compared, since projection expressions are not supported; it is only useful nested as the argument of a boolean or numeric one, such as
geof:sfContains(geof:envelope(?g), ...).
Each geometry argument of a function is one of:
| Argument | Becomes |
|---|---|
A variable bound in object position, e.g. the ?wkt of ?g geo:asWKT ?wkt |
tN.object_geometry |
A geo:wktLiteral, e.g. "POINT(-89.5 40.5)"^^geo:wktLiteral |
ST_GeomFromText('POINT(-89.5 40.5)') |
A nested geometry-valued geof: call |
The nested ST_ call |
A plain string literal is accepted as WKT too, since the datatype is only used to reject what is certainly not WKT. A variable bound as a subject or predicate, a geo:gmlLiteral, or a boolean function where a geometry is expected is an error that names the problem.
Examples with their SQL
Section titled “Examples with their SQL”Everything intersecting a bounding box, with the name of each feature. The ?wkt variable is bound in object position by the geo:asWKT pattern, so it reads the geometry column directly; the literal becomes ST_GeomFromText:
PREFIX schema: <https://schema.org/>PREFIX geo: <http://www.opengis.net/ont/geosparql#>PREFIX geof: <http://www.opengis.net/def/function/geosparql/>
SELECT ?place ?name ?wktWHERE { ?place schema:name ?name . ?place geo:hasGeometry ?geometry . ?geometry geo:asWKT ?wkt . FILTER(geof:sfIntersects(?wkt, "POLYGON((-91 39, -88 39, -88 42, -91 42, -91 39))"^^geo:wktLiteral))}SELECT t0.subject AS "place", COALESCE(t0.object_iri, CAST(t0.object_float AS VARCHAR), CAST(t0.object_integer AS VARCHAR), CAST(t0.object_byte AS VARCHAR), replace(CAST(t0.object_time AS VARCHAR), ' ', 'T') || 'Z', t0.object_string, ST_AsText(t0.object_geometry)) AS "name", COALESCE(t2.object_iri, CAST(t2.object_float AS VARCHAR), CAST(t2.object_integer AS VARCHAR), CAST(t2.object_byte AS VARCHAR), replace(CAST(t2.object_time AS VARCHAR), ' ', 'T') || 'Z', t2.object_string, ST_AsText(t2.object_geometry)) AS "wkt"FROM triples AS t0CROSS JOIN triples AS t1CROSS JOIN triples AS t2WHERE t0.predicate = 'https://schema.org/name' AND t0.subject = t1.subject AND t1.predicate = 'http://www.opengis.net/ont/geosparql#hasGeometry' AND COALESCE(t1.object_iri, CAST(t1.object_float AS VARCHAR), CAST(t1.object_integer AS VARCHAR), CAST(t1.object_byte AS VARCHAR), replace(CAST(t1.object_time AS VARCHAR), ' ', 'T') || 'Z', t1.object_string) = t2.subject AND t2.predicate = 'http://www.opengis.net/ont/geosparql#asWKT' AND ST_Intersects(t2.object_geometry, ST_GeomFromText('POLYGON((-91 39, -88 39, -88 42, -91 42, -91 39))'))Everything within 25 km of a point. The uom:metre unit selects the spherical distance:
PREFIX geo: <http://www.opengis.net/ont/geosparql#>PREFIX geof: <http://www.opengis.net/def/function/geosparql/>PREFIX uom: <http://www.opengis.net/def/uom/OGC/1.0/>
SELECT ?place ?wktWHERE { ?place geo:hasGeometry ?geometry . ?geometry geo:asWKT ?wkt . FILTER(geof:distance(?wkt, "POINT(-89.5 40.5)"^^geo:wktLiteral, uom:metre) < 25000)}SELECT t0.subject AS "place", COALESCE(t1.object_iri, CAST(t1.object_float AS VARCHAR), CAST(t1.object_integer AS VARCHAR), CAST(t1.object_byte AS VARCHAR), replace(CAST(t1.object_time AS VARCHAR), ' ', 'T') || 'Z', t1.object_string, ST_AsText(t1.object_geometry)) AS "wkt"FROM triples AS t0CROSS JOIN triples AS t1WHERE t0.predicate = 'http://www.opengis.net/ont/geosparql#hasGeometry' AND COALESCE(t0.object_iri, CAST(t0.object_float AS VARCHAR), CAST(t0.object_integer AS VARCHAR), CAST(t0.object_byte AS VARCHAR), replace(CAST(t0.object_time AS VARCHAR), ' ', 'T') || 'Z', t0.object_string) = t1.subject AND t1.predicate = 'http://www.opengis.net/ont/geosparql#asWKT' AND ST_Distance_Sphere(t1.object_geometry, ST_GeomFromText('POINT(-89.5 40.5)')) < 25000Relations between two geometries in the data, rather than against a literal. Each geo:asWKT pattern scans the table once, so this is a self-join, and the FILTER compares the two geometry columns:
PREFIX geo: <http://www.opengis.net/ont/geosparql#>PREFIX geof: <http://www.opengis.net/def/function/geosparql/>
SELECT ?a ?bWHERE { ?a geo:asWKT ?ga . ?b geo:asWKT ?gb . FILTER(geof:sfWithin(?ga, ?gb) && !geof:sfEquals(?ga, ?gb))}SELECT t0.subject AS "a", t1.subject AS "b"FROM triples AS t0CROSS JOIN triples AS t1WHERE t0.predicate = 'http://www.opengis.net/ont/geosparql#asWKT' AND t1.predicate = 'http://www.opengis.net/ont/geosparql#asWKT' AND (ST_Within(t0.object_geometry, t1.object_geometry) AND NOT (ST_Equals(t0.object_geometry, t1.object_geometry)))A geometry constructor nested inside a relation, asking which geometries have a bounding box that contains a point:
PREFIX geo: <http://www.opengis.net/ont/geosparql#>PREFIX geof: <http://www.opengis.net/def/function/geosparql/>
SELECT ?geometryWHERE { ?geometry geo:asWKT ?wkt . FILTER(geof:sfContains(geof:envelope(?wkt), "POINT(-89.5 40.5)"^^geo:wktLiteral))}SELECT t0.subject AS "geometry"FROM triples AS t0WHERE t0.predicate = 'http://www.opengis.net/ont/geosparql#asWKT' AND ST_Contains(ST_Envelope(t0.object_geometry), ST_GeomFromText('POINT(-89.5 40.5)'))Press F2 in sal query --sparql to see the translation of whatever is in the editor.
Geometry in results
Section titled “Geometry in results”A projected object variable is rendered with ST_AsText, so a geometry comes back as WKT, such as POINT (-89.5 40.5), the same way it went in minus any CRS prefix. Over the HTTP endpoint such a binding is a literal with "datatype": "http://www.opengis.net/ont/geosparql#wktLiteral", the one datatype the endpoint reports. The Map tab of sal serve --with-ui recognizes those WKT values in the last SPARQL result and draws them, with the other columns of the row as each feature’s properties; see --with-ui.
Limitations
Section titled “Limitations”- One coordinate system. CRS IRIs are dropped, not honored. All geometries are taken to share the CRS84 longitude/latitude frame the table declares, and
geof:distancein degrees is a planar distance in that frame. - Metres between points only.
uom:metreis answered withST_Distance_Sphere, which DuckDB defines for points; between lines or polygons it is an error. Use degrees, or a bounding box, for those. - No geometry output. Because projection expressions are not supported, a query cannot
SELECT (geof:envelope(?g) AS ?box); geometry-valued functions only appear nested in aFILTER. - No spatial index. A relation against a literal evaluates the
ST_function on every geometry row. That is quick for the sizes a data product usually holds, but it is a scan, not an R-tree lookup. - First use loads the extension. DuckDB’s spatial extension is loaded on the first query that needs it, which adds a moment to that query and nothing to the ones after it in the same process.
For spatial work beyond this subset, the same object_geometry column is available to the full set of DuckDB ST_ functions through sal query and POST /api/sql, and GET /geometries?bbox= answers a bounding box directly as GeoJSON; see When the subset is not enough.
Examples
Section titled “Examples”Every distinct predicate in the data product:
SELECT DISTINCT ?pWHERE { ?s ?p ?o .}LIMIT 25Everything a resource states:
SELECT ?p ?oWHERE { <https://example.org/alice> ?p ?o .}Instances of a class, with a property of each:
PREFIX schema: <https://schema.org/>
SELECT ?s ?nameWHERE { ?s a schema:Person . ?s schema:name ?name .}Which version of each vocabulary the build validated against, from the provenance sal build writes into the table:
PREFIX rdf: <http://www.w3.org/1999/02/22-rdf-syntax-ns#>PREFIX owl: <http://www.w3.org/2002/07/owl#>PREFIX dcterms: <http://purl.org/dc/terms/>
SELECT ?ontology ?versionIRI ?format ?modifiedWHERE { ?ontology rdf:type owl:Ontology . ?ontology owl:versionIRI ?versionIRI . ?ontology dcterms:format ?format . ?ontology dcterms:modified ?modified .}See Pinned vocabularies for what those triples mean.
The HTTP endpoint
Section titled “The HTTP endpoint”sal serve implements the SPARQL Protocol query operation at /sparql:
GET /sparql?query=...POST /sparqlwithContent-Type: application/sparql-queryand the query as the bodyPOST /sparqlwithContent-Type: application/x-www-form-urlencodedand aqueryfield
The response is application/sparql-results+json. A request whose Accept header asks for anything other than that, application/json, or a wildcard is answered 406; a query the translator rejects is answered 400 with the error message as the body. CORS is open, so a browser client on another origin can query it.
curl -s 'http://localhost:8080/sparql' \ -H 'Accept: application/sparql-results+json' \ --data-urlencode 'query=SELECT ?s ?p ?o WHERE { ?s ?p ?o . } LIMIT 1'{ "head": { "vars": ["s", "p", "o"] }, "results": { "bindings": [ { "s": { "type": "uri", "value": "https://example.org/alice" }, "p": { "type": "uri", "value": "https://schema.org/name" }, "o": { "type": "literal", "value": "Alice" } } ] }}Because results come back from SQL as text, a binding’s type is inferred from its value rather than carried from the table: a value starting with http:// or https:// is reported as a uri, one starting with _: as a bnode, and everything else as a literal. Language tags are not reported, and the only datatype that is is geo:wktLiteral, on a literal that reads as WKT — the one datatype a value gives away on its own.
When the subset is not enough
Section titled “When the subset is not enough”Two ways around it, depending on what you need:
- Query the table as SQL.
sal queryopens the sametriplesview in a DuckDB shell, andsal serve --with-uiexposesPOST /api/sql. Aggregation, ordering, window functions, the full set ofST_geometry functions, and joins against imported data products are all available there. A project that imported a data product withsal import oci://...gets a view per imported table plus animportsview stacking them, none of which SPARQL can reach — SPARQL only ever queries the project’s owntriplesview. - Take the graph elsewhere.
sal exportwrites the data product as N-Triples, which any full SPARQL engine can load.

