SPARQL for People Who Know SQL
If you can write SQL, you already know 70% of SPARQL; the remaining 30% is where all the power lives. SPARQL is the W3C standard query language for RDF, and the mental translation from SQL is direct: SELECT clauses, WHERE conditions, GROUP BY, ORDER BY, aggregates and subqueries all exist and behave much as you expect. What differs is the thing being matched: instead of joining named tables on foreign keys, you write graph patterns, and the engine finds every way the data can satisfy them. Joins become implicit, schema becomes optional, and queries can reach across the public web. This lesson teaches SPARQL through the SQL you already own, then covers the features SQL has no analogue for.

The core move: triple patterns instead of table joins
A SPARQL query’s WHERE clause contains triple patterns: triples in which any position may be a variable, written with a question mark. The pattern ?pump a ex:Pump . ?pump ex:installedAt ?site matches every pump and its site. Notice what happened to the join: in SQL you would join pumps to sites via a foreign key column you must know by name; in SPARQL, sharing the variable ?pump between two patterns is the join. Multi-hop questions that need four JOIN clauses in SQL are simply four triple patterns sharing variables. A complete query reads: SELECT ?pump ?site WHERE { ?pump a ex:Pump ; ex:installedAt ?site . } and returns a familiar result table. The semicolon idiom from Turtle carries over, and this is deliberate: SPARQL patterns are Turtle with variables, so everything you learned in lesson 5 transfers directly.
The familiar toolkit maps almost one to one. FILTER is your WHERE-clause predicate: FILTER(?ratedPower > 15). OPTIONAL is LEFT OUTER JOIN: match this pattern if you can, bind nothing if you cannot. UNION is UNION. GROUP BY, HAVING, COUNT, SUM, AVG, MIN, MAX behave as in SQL, as do ORDER BY, LIMIT and OFFSET, subqueries, and VALUES for inline data. Negation deserves care: FILTER NOT EXISTS { ?pump ex:inspectedBy ?anyone } finds pumps with no recorded inspection, and as lesson 4 established, this is closed-world negation over the dataset at hand, not a claim that no inspection ever happened. That distinction, trivial in SQL where the database is the world, is load-bearing in a stack built on the open-world assumption.
| SQL construct | SPARQL equivalent | Note |
|---|---|---|
| SELECT columns FROM table | SELECT ?vars WHERE { triple patterns } | No FROM table; patterns match the whole graph |
| JOIN … ON fk = pk | Shared variable across triple patterns | Joins are implicit; no key names needed |
| LEFT OUTER JOIN | OPTIONAL { … } | Unbound variables replace NULLs |
| WHERE price > 100 | FILTER(?price > 100) | Rich function library: STR, REGEX, YEAR, COALESCE |
| NOT EXISTS subquery | FILTER NOT EXISTS { … } or MINUS | Closed-world over the current dataset |
| GROUP BY, HAVING, aggregates | Identical keywords and behaviour | Aggregate then FILTER via HAVING, as in SQL |
| Recursive CTE for hierarchies | Property path: ex:partOf+ or rdfs:subClassOf* | One operator replaces the entire recursive query |
| CREATE VIEW / SELECT INTO | CONSTRUCT { new triples } WHERE { … } | Returns a graph, not a table |
| Cross-database query | SERVICE <endpoint> { … } | Federation across live endpoints, no ETL |
Worked example · the Aberdeen-2 asset register
Where we left it: schema v0.1 over the source-partitioned data, with disjointness catching category errors and a governed SKOS scheme absorbing the three source dialects.
Three queries against the register as it stands, each cashing in a decision made in an earlier lesson:
# 1. every pump on the platform
SELECT ?p WHERE { ?p a ex:Pump ; ex:installedAt ex:Aberdeen2 . }
# 2. pumps above 15 kW: works because 18.5 was loaded
# as a typed decimal in lesson 5, not a string
SELECT ?p ?kw WHERE { ?p a ex:Pump ; ex:ratedPowerKW ?kw .
FILTER(?kw > 15) }
# 3. the audit view: every valve, inspection date if known
SELECT ?v ?insp WHERE { ?v a ex:Valve .
OPTIONAL { ?v ex:lastInspected ?insp } }
Query 3 is the interesting one. V-205 comes back with an empty ?insp cell: OPTIONAL keeps the valve in the result instead of silently dropping it, which makes the blank cell lesson 4’s “unknown” rendered visible to a human. An inner-join mentality would have excluded V-205 from the very audit that most needs to see it.
State of the register: the register answers questions. What it cannot yet do is refuse bad data at the report boundary, which is the next lesson’s job.
What breaks · the filter that eats untyped rows
The contractor spreadsheet was loaded in a hurry, and its power ratings arrived as plain strings. Now the fleet report runs query 2:
ex:P204 ex:ratedPowerKW "22.0" . # untyped string from the CSV load
FILTER(?kw > 15) # comparing a string with a number is a type
# error, and a FILTER error ELIMINATES the row
P-204, a 22 kW pump, vanishes from the over-15 kW report. Not with an error message: SPARQL semantics treat a failed comparison as an eliminated solution, so the query succeeds, the report renders, and the number is wrong. This is the quiet cousin of lesson 6’s silent reclassification, and it is why lesson 5 insisted on typing literals at load time. The band-aid, FILTER(xsd:decimal(?kw) > 15), rescues clean strings and still drops “22 kW” and “TBC”, at which point you are validating data inside every query that touches it. The real fix is upstream: type at ingestion, and let the next lesson’s shapes enforce it once, for every query thereafter.
The four query forms, and why CONSTRUCT changes your architecture
SQL has one query form; SPARQL has four. SELECT returns a result table. ASK returns a boolean: does this pattern exist at all? It is the cheapest possible test and the backbone of validation scripts and competency-question suites (lesson 27 turns ASK queries into a CI test harness). DESCRIBE returns what the endpoint knows about a resource, useful for exploration. CONSTRUCT is the one with no SQL analogue worth the name: it returns a brand-new RDF graph built from the match results. CONSTRUCT { ?p ex:locatedIn ?region } WHERE { ?p ex:installedAt ?s . ?s ex:inRegion ?region } derives a shortcut relationship from a two-hop pattern, and because its output is RDF, it feeds straight back into a graph, another query, or a downstream system. Whole architectures hang off this: graph-to-graph transformation, ontology-mediated data exchange, rule-like enrichment, and the mapping pipelines you will meet in the crosswalk lessons are all CONSTRUCT at heart. SPARQL Update completes the picture with INSERT DATA, DELETE DATA and the pattern-driven DELETE/INSERT WHERE, the graph counterpart of UPDATE statements.
Property paths: the feature SQL wishes it had
Hierarchies and reachability are where SQL becomes miserable: recursive common table expressions, correctness traps, vendor variance. SPARQL 1.1 property paths compress all of it into regex-like operators over predicates. ex:partOf+ means one or more partOf hops: every assembly a component ultimately belongs to. rdfs:subClassOf* means zero or more hops: a class and all its ancestors. The vertical bar gives alternatives (skos:broader|skos:related), the caret inverts direction (^ex:installedAt finds what is installed at a site), and sequences chain hops (ex:installedAt/ex:inRegion jumps pump to region in one pattern). One line, ?x rdf:type/rdfs:subClassOf* ex:Equipment, retrieves every instance of Equipment or any of its transitive subclasses: the single most useful SPARQL idiom in ontology-backed systems, and a query that grows not one character as the hierarchy deepens.
Federation: querying graphs you do not host
The SERVICE keyword sends a subpattern to a remote SPARQL endpoint mid-query and joins the results with your local matches. Ask your own graph for suppliers, then, inside the same query, ask Wikidata for each supplier’s headquarters country and stock exchange listing: no ETL, no API integration code, one declarative query across organisational boundaries. Federation has real operational costs (you inherit the remote endpoint’s latency and availability, and naive queries can ship enormous intermediate results), so production use favours small, selective SERVICE blocks. But as a capability it has no SQL counterpart at all, and it is the reason public endpoints matter.
Which brings us to the practice ground. The Wikidata Query Service is the best free SPARQL gymnasium in existence: more than a hundred million items, a live editor with autocompletion, built-in visualisations and a huge library of example queries. Wikidata’s numeric vocabulary (wdt:P31 for “instance of”, wdt:P279 for “subclass of”) looks alien for about an hour, then the autocompletion makes it fluent. A worthwhile exercise after this lesson: reproduce three of your everyday SQL reports as SPARQL queries against Wikidata’s domain-appropriate data, using at least one property path and one OPTIONAL.
SPARQL 1.2 and performance habits
SPARQL is moving with RDF: the same W3C working group (chartered May 2025 to April 2027) has SPARQL 1.2 Query at Candidate Recommendation from Q3 2025, with the headline capability being direct querying of RDF 1.2 triple terms, so statement-level metadata like the provenance and confidence annotations from lesson 5 becomes queryable without reification gymnastics. Engines are adopting progressively; Oxigraph, for example, ships SPARQL 1.2 support behind a feature flag. For everyday performance, five habits cover most cases: start patterns with the most selective term so the engine narrows early; prefer specific predicates over unbounded variable-predicate patterns; keep OPTIONAL blocks small and late, since each one is an outer join; put LIMIT on every exploratory query before pointing it at a billion-triple store; and remember that inference strategy (lesson 15) determines whether your query must traverse hierarchies itself or can rely on materialised entailments, which is frequently the real reason “the same query” is fast on one store and slow on another.
What to practise before moving on
Fluency here is mechanical, not conceptual: an experienced SQL developer typically reaches working SPARQL competence in days precisely because the mapping in this lesson’s table holds so well. Drill until three moves are reflexive: multi-hop joins as shared variables, hierarchy traversal as property paths, and graph production as CONSTRUCT. Everything downstream in this course consumes them: SHACL’s advanced constraints embed SPARQL directly (next lesson), competency-question testing runs on ASK and SELECT, GraphRAG retrievers in module 6 compile natural-language questions into exactly these patterns, and the agent tooling of lesson 43 exposes SPARQL endpoints to LLMs through MCP servers. The query language, in other words, is not one skill among many; it is the interface through which every other layer of the stack becomes usable. Next: SHACL, the validation layer, and the lesson this course intends to make the definitive modern treatment of it.
Check yourself
The compliance officer wants the valves that have NO inspection record in the register. Which query delivers?
- SELECT ?v WHERE { ?v a ex:Valve . FILTER NOT EXISTS { ?v ex:lastInspected ?d } }
- SELECT ?v WHERE { ?v a ex:Valve . OPTIONAL { ?v ex:lastInspected ?d } }
- SELECT ?v WHERE { ?v a ex:Valve ; ex:lastInspected ?d . FILTER(?d = “”) }
- SELECT ?v WHERE { ?v a ex:Valve . MINUS { ?x ex:lastInspected ?d } }
Show the answer, and why each wrong option is wrong
A is correct. Closed-world negation over the dataset: keep each valve for which the inspection pattern finds no match. (The OPTIONAL variant of B followed by FILTER(!BOUND(?d)) is the other correct idiom; the two are interchangeable here.)
B as written returns every valve, inspected or not; OPTIONAL never filters anything, it only permits absence. The missing !BOUND test is the single most common SPARQL negation bug. C is the SQL NULL habit: it requires the lastInspected triple to exist and then hunts for an empty-string date, a value well-typed data never contains, so it returns nothing at all. In RDF, absence is the absence of the triple, not a sentinel value. D looks like A but uses ?x instead of ?v inside the MINUS. MINUS removes solutions only where bindings share variables; with none shared, it removes nothing and every valve comes back. This divergence between MINUS and NOT EXISTS is exactly why the working idioms are worth memorising rather than improvising.

