Skip to content

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.

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.

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"]
  1. Parse. The query text is parsed by goRDFlib’s SPARQL parser. A query that is not a SELECT, or that uses a solution modifier the translator cannot express, is rejected here.
  2. Flatten the pattern. The WHERE clause is walked and reduced to a flat list of triple patterns plus a list of FILTER expressions. A group pattern that is neither, such as OPTIONAL or UNION, ends the walk with an error.
  3. Translate. Each triple pattern becomes one self-join of the triples view; constants become WHERE equalities and shared variables become join conditions. This is described in The translation below.
  4. Execute. DuckDB runs the statement against the Iceberg table through the triples view, reading the current snapshot. DuckDB is linked into the sal binary, so no external database or duckdb CLI is involved.
  5. 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.

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 ?age
WHERE {
?s schema:name "bob" .
?s schema:age ?age .
}

becomes:

SELECT t0.subject AS "s", t1.object AS "age"
FROM triples AS t0
CROSS JOIN triples AS t1
WHERE 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.

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.

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.

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.

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 ?s
WHERE {
?s schema:age ?age .
FILTER(?age >= 21 && ?age < 65)
}
SELECT t0.subject AS "s"
FROM triples AS t0
WHERE 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 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 a geo:hasGeometry, and that geometry carries its shape as a geo:asWKT literal typed geo:wktLiteral. This is plain RDF, and sal build handles it like any other data.
  • The functions, geof: (http://www.opengis.net/def/function/geosparql/), are how geometry is queried: topological relations such as geof:sfIntersects, geof:distance, and geometry constructors such as geof: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.

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.

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.

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 ?wkt
WHERE {
?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 t0
CROSS JOIN triples AS t1
CROSS JOIN triples AS t2
WHERE 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 ?wkt
WHERE {
?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 t0
CROSS JOIN triples AS t1
WHERE 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)')) < 25000

Relations 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 ?b
WHERE {
?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 t0
CROSS JOIN triples AS t1
WHERE 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 ?geometry
WHERE {
?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 t0
WHERE 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.

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.

  • 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:distance in degrees is a planar distance in that frame.
  • Metres between points only. uom:metre is answered with ST_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 a FILTER.
  • 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.

Every distinct predicate in the data product:

SELECT DISTINCT ?p
WHERE {
?s ?p ?o .
}
LIMIT 25

Everything a resource states:

SELECT ?p ?o
WHERE {
<https://example.org/alice> ?p ?o .
}

Instances of a class, with a property of each:

PREFIX schema: <https://schema.org/>
SELECT ?s ?name
WHERE {
?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 ?modified
WHERE {
?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.

sal serve implements the SPARQL Protocol query operation at /sparql:

  • GET /sparql?query=...
  • POST /sparql with Content-Type: application/sparql-query and the query as the body
  • POST /sparql with Content-Type: application/x-www-form-urlencoded and a query field

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.

Terminal window
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.

Two ways around it, depending on what you need:

  • Query the table as SQL. sal query opens the same triples view in a DuckDB shell, and sal serve --with-ui exposes POST /api/sql. Aggregation, ordering, window functions, the full set of ST_ geometry functions, and joins against imported data products are all available there. A project that imported a data product with sal import oci://... gets a view per imported table plus an imports view stacking them, none of which SPARQL can reach — SPARQL only ever queries the project’s own triples view.
  • Take the graph elsewhere. sal export writes the data product as N-Triples, which any full SPARQL engine can load.
Developed byCenter for Geospatial SolutionsCenter for Geospatial Solutions