Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

SPARQL from Zero

This tutorial teaches SPARQL using a concrete homelab dataset. Every query has sample data, the query itself, and the results table — so you can follow along by loading the data into Quipu and running the queries yourself.

The Sample Dataset

Save this as homelab.ttl:

@prefix hw:   <http://example.org/homelab/> .
@prefix xsd:  <http://www.w3.org/2001/XMLSchema#> .
@prefix rdfs: <http://www.w3.org/2000/01/rdf-schema#> .
@prefix rdf:  <http://www.w3.org/1999/02/22-rdf-syntax-ns#> .

# --- Hosts ---
hw:koror      a hw:Host ;
    rdfs:label      "koror" ;
    hw:hostname     "koror.example" ;
    hw:cpuCores     "8"^^xsd:integer ;
    hw:memoryMB     "32768"^^xsd:integer ;
    hw:role         "hypervisor" .

hw:palau      a hw:Host ;
    rdfs:label      "palau" ;
    hw:hostname     "palau.example" ;
    hw:cpuCores     "4"^^xsd:integer ;
    hw:memoryMB     "16384"^^xsd:integer ;
    hw:role         "storage" .

hw:yap        a hw:Host ;
    rdfs:label      "yap" ;
    hw:hostname     "yap.example" ;
    hw:cpuCores     "4"^^xsd:integer ;
    hw:memoryMB     "8192"^^xsd:integer ;
    hw:role         "edge" .

# --- Services ---
hw:traefik    a hw:WebApp ;
    rdfs:label      "traefik" ;
    hw:runsOn       hw:koror ;
    hw:port         "443"^^xsd:integer ;
    hw:dependsOn    hw:pihole .

hw:pihole     a hw:Service ;
    rdfs:label      "pihole" ;
    hw:runsOn       hw:koror ;
    hw:port         "53"^^xsd:integer .

hw:grafana    a hw:WebApp ;
    rdfs:label      "grafana" ;
    hw:runsOn       hw:koror ;
    hw:port         "3000"^^xsd:integer ;
    hw:dependsOn    hw:prometheus .

hw:prometheus a hw:Service ;
    rdfs:label      "prometheus" ;
    hw:runsOn       hw:palau ;
    hw:port         "9090"^^xsd:integer .

hw:minio      a hw:Service ;
    rdfs:label      "minio" ;
    hw:runsOn       hw:palau ;
    hw:port         "9000"^^xsd:integer .

hw:nginx      a hw:WebApp ;
    rdfs:label      "nginx" ;
    hw:runsOn       hw:yap ;
    hw:port         "80"^^xsd:integer ;
    hw:dependsOn    hw:minio .

# --- Type hierarchy ---
hw:WebApp     rdfs:subClassOf hw:Service .

Load it:

quipu knot homelab.ttl --db homelab.db

1. Your First Query: SELECT

A SPARQL query matches patterns against the graph. The simplest pattern is a single triple with a variable:

SELECT ?host
WHERE {
  ?host a <http://example.org/homelab/Host> .
}

This says “find every ?host that has type hw:Host.” The a keyword is shorthand for rdf:type.

Run it:

quipu read "SELECT ?host WHERE { ?host a <http://example.org/homelab/Host> }" \
  --db homelab.db
?host
http://example.org/homelab/koror
http://example.org/homelab/palau
http://example.org/homelab/yap

2. Multiple Patterns: JOIN

Add more patterns to narrow results. Patterns in the same WHERE block are joined — every pattern must match:

SELECT ?host ?cores
WHERE {
  ?host a <http://example.org/homelab/Host> .
  ?host <http://example.org/homelab/cpuCores> ?cores .
}
?host?cores
hw:koror8
hw:palau4
hw:yap4

3. Using Prefixes

Full IRIs are verbose. SPARQL supports PREFIX declarations (without the @ and trailing . that Turtle uses):

PREFIX hw: <http://example.org/homelab/>

SELECT ?host ?cores
WHERE {
  ?host a hw:Host .
  ?host hw:cpuCores ?cores .
}

The results are identical. Use prefixes in every query from here on.

4. FILTER: Narrowing Results

FILTER applies conditions to bound variables:

PREFIX hw:  <http://example.org/homelab/>
PREFIX xsd: <http://www.w3.org/2001/XMLSchema#>

SELECT ?host ?mem
WHERE {
  ?host a hw:Host .
  ?host hw:memoryMB ?mem .
  FILTER(?mem > 10000)
}
?host?mem
hw:koror32768
hw:palau16384

String filters

PREFIX hw: <http://example.org/homelab/>

SELECT ?svc ?label
WHERE {
  ?svc a hw:Service .
  ?svc <http://www.w3.org/2000/01/rdf-schema#label> ?label .
  FILTER(CONTAINS(?label, "pi"))
}
?svc?label
hw:pihole“pihole”

Available filter functions: =, !=, <, >, <=, >=, &&, ||, !, BOUND(), CONTAINS(), REGEX(), LCASE(), isIRI().

5. OPTIONAL: Left Joins

Not every service has a dependsOn edge. OPTIONAL includes the match if it exists, but doesn’t exclude the row if it doesn’t:

PREFIX hw: <http://example.org/homelab/>

SELECT ?svc ?dep
WHERE {
  ?svc a hw:Service .
  OPTIONAL { ?svc hw:dependsOn ?dep . }
}
?svc?dep
hw:traefikhw:pihole
hw:pihole
hw:grafanahw:prometheus
hw:prometheus
hw:minio
hw:nginxhw:minio

Services without dependencies appear with an empty ?dep column.

6. UNION: Combining Patterns

UNION matches rows from either branch:

PREFIX hw: <http://example.org/homelab/>

SELECT ?thing ?label
WHERE {
  {
    ?thing a hw:Host .
    ?thing <http://www.w3.org/2000/01/rdf-schema#label> ?label .
  }
  UNION
  {
    ?thing a hw:WebApp .
    ?thing <http://www.w3.org/2000/01/rdf-schema#label> ?label .
  }
}

Returns all hosts and web apps.

7. ORDER BY, LIMIT, OFFSET

Sort and paginate results:

PREFIX hw: <http://example.org/homelab/>

SELECT ?host ?mem
WHERE {
  ?host a hw:Host .
  ?host hw:memoryMB ?mem .
}
ORDER BY DESC(?mem)
LIMIT 2
?host?mem
hw:koror32768
hw:palau16384

OFFSET 1 LIMIT 1 would skip koror and return only palau.

8. Aggregates: COUNT, SUM, AVG

Group and aggregate with GROUP BY:

PREFIX hw: <http://example.org/homelab/>

SELECT ?host (COUNT(?svc) AS ?serviceCount)
WHERE {
  ?svc hw:runsOn ?host .
}
GROUP BY ?host
ORDER BY DESC(?serviceCount)
?host?serviceCount
hw:koror3
hw:palau2
hw:yap1

Total resources

PREFIX hw: <http://example.org/homelab/>

SELECT (SUM(?cores) AS ?totalCores) (SUM(?mem) AS ?totalMem)
WHERE {
  ?host a hw:Host .
  ?host hw:cpuCores ?cores .
  ?host hw:memoryMB ?mem .
}
?totalCores?totalMem
1657344

HAVING: Filter on aggregates

PREFIX hw: <http://example.org/homelab/>

SELECT ?host (COUNT(?svc) AS ?n)
WHERE {
  ?svc hw:runsOn ?host .
}
GROUP BY ?host
HAVING(?n > 1)
?host?n
hw:koror3
hw:palau2

9. RDFS Subclass Inference

Remember that hw:WebApp rdfs:subClassOf hw:Service. A constant type form infers; a variable type form does not. The two spellings answer different questions, and the response says which one you got.

PREFIX hw: <http://example.org/homelab/>

SELECT ?svc
WHERE {
  ?svc a hw:Service .
}

This returns every service including those asserted only as hw:WebApp, because a constant type form is expanded over rdfs:subClassOf. Quipu’s formal default is reasoning-on.

Two spellings, two questions

# INFERS — a constant type is expanded over rdfs:subClassOf.  -> 6
SELECT (COUNT(DISTINCT ?s) AS ?n) WHERE { ?s a hw:Service }

# ASSERTED ONLY — a VARIABLE type plus a filter is not expanded.  -> 3
SELECT (COUNT(DISTINCT ?s) AS ?n) WHERE { ?s a ?t . FILTER(?t = hw:Service) }

On this tutorial’s dataset that is 6 against 3: three entities are asserted hw:Service (pihole, prometheus, minio) and three are asserted hw:WebApp (traefik, grafana, nginx), which the constant form folds in.

QuestionForm
“what depends on X”, blast radius, impact, “find it the way a reader would”constant form ?s a hw:Service (infers)
“is this entity DIRECTLY typed hw:Service?”?s a ?t . FILTER(?t = hw:Service)
vocabulary census, governance gating, “who emits the wrong type”the asserted-only form

⚠️ A hit on the constant form does not prove direct typing. An entity typed only as hw:WebApp satisfies ?s a hw:Service. If you are checking that something carries a type — a governance gate, an ingest read-back — the constant form will pass on a subclass and tell you nothing was wrong. Use the variable form plus a filter, or read the marker below.

The explicit path form remains available and is unaffected by the default:

SELECT (COUNT(DISTINCT ?s) AS ?n) WHERE { ?s a/rdfs:subClassOf* hw:Service }

The marker: what was folded in

Ambiguity here is expensive: both spellings return HTTP 200 and both counts are individually plausible, so a number answering the other question does not look wrong. Quipu therefore reports the expansion, on exactly the queries whose answer it could have changed:

{
  "count": 6,
  "inference": {
    "applied": true,
    "expandedTypes": [
      {"type": "http://example.org/homelab/Service",
       "subclasses": ["http://example.org/homelab/WebApp"]}
    ],
    "note": "RDFS subclass expansion was applied to the constant rdf:type pattern; use a variable type plus FILTER for an asserted-only census"
  }
}

The field is absent when the query was not expanded, so its presence is the signal. The subclasses are named because “inference happened” is not actionable on its own — the reader needs to know that Service swallowed WebApp. A leaf type is never reported: with no subclasses there is nothing to fold in.

History, so an older reading does not mislead. For a period this page documented the opposite — an asserted-only constant form announcing "applied": false with a withheldTypes list. That flip was reverted when formal reasoning defaults were enabled, and that marker shape no longer exists. If you are looking for withheldTypes, you are reading a build that is gone; branch on inference.applied instead.

Every result shape carries it — including ASK

The marker is not a SELECT feature. ASK is the shape most in need of it:

ASK { hw:postgres a hw:Service }     # -> {"result": false, "inference": {...}}

hw:postgres may be asserted only as hw:DatabaseService — nothing in the graph says it is a hw:Service, so this now answers false. A boolean gives you no number to look at twice, so the marker makes the semantic change visible. CONSTRUCT/DESCRIBE carry it too: their formerly inferred triples are likewise withheld unless the query uses the explicit path.

What the marker claims. It says the old implicit expansion was withheld from this query. It does not say the resulting answer necessarily changed: a marked ASK can still be true about a directly asserted fact. To ask the inferred question, use the explicit path; to inspect asserted types, ask:

SELECT ?t WHERE { hw:postgres a ?t }    # what is it ACTUALLY typed as?

Standard result formats: the marker moves to a header

If you request a W3C shape with Accept (application/sparql-results+json, application/sparql-results+xml, text/turtle), the body is fixed by spec and has nowhere to put the marker. It travels as a response header instead, naming the affected type constants:

x-quipu-inference: withheld: http://example.org/homelab/Service

Same rule: the header is absent when the flip did not affect the query. The body is untouched and stays conformant, so a standard parser is unaffected — but a client that ignores headers gets no signal, which is a reason to prefer the default JSON shape when the distinction matters. Full withheldTypes detail is one Accept-free request away.

10. Property Paths

SPARQL 1.1 property paths let you traverse edges without binding intermediate variables.

Sequence (/)

“What hosts do web apps’ dependencies run on?”

PREFIX hw: <http://example.org/homelab/>

SELECT ?app ?depHost
WHERE {
  ?app a hw:WebApp .
  ?app hw:dependsOn/hw:runsOn ?depHost .
}

hw:dependsOn/hw:runsOn means: follow dependsOn, then follow runsOn.

?app?depHost
hw:traefikhw:koror
hw:grafanahw:palau
hw:nginxhw:palau

Transitive closure (* and +)

If you had a chain like A dependsOn B dependsOn C, you could traverse the full dependency chain:

PREFIX hw: <http://example.org/homelab/>

SELECT ?svc ?transitiveDep
WHERE {
  ?svc hw:dependsOn+ ?transitiveDep .
}

+ means “one or more hops.” * means “zero or more” (includes the starting node itself).

Alternative (|)

Match either predicate:

PREFIX hw: <http://example.org/homelab/>

SELECT ?thing ?name
WHERE {
  ?thing (hw:hostname|<http://www.w3.org/2000/01/rdf-schema#label>) ?name .
}

Reverse (^)

“What services does koror host?” using reverse traversal:

PREFIX hw: <http://example.org/homelab/>

SELECT ?svc
WHERE {
  hw:koror ^hw:runsOn ?svc .
}

^hw:runsOn means “follow runsOn edges backwards.”

11. Temporal Queries

Every SPARQL query in Quipu can include a temporal context.

Valid-time travel

“What did the homelab look like on March 15?”

quipu read "PREFIX hw: <http://example.org/homelab/>
SELECT ?host ?cores WHERE {
  ?host a hw:Host .
  ?host hw:cpuCores ?cores .
}" --db homelab.db --valid-at 2026-03-15

Via REST:

curl -s localhost:3030/query -X POST \
  -H "Content-Type: application/json" \
  -d '{
    "query": "PREFIX hw: <http://example.org/homelab/> SELECT ?host ?cores WHERE { ?host a hw:Host . ?host hw:cpuCores ?cores }",
    "valid_at": "2026-03-15"
  }'

Transaction-time travel

“What did the database know after the first 5 transactions?”

quipu read "SELECT ?s ?p ?o WHERE { ?s ?p ?o }" --db homelab.db --tx 5

12. Other Query Forms

ASK: Yes/No Questions

PREFIX hw: <http://example.org/homelab/>

ASK { hw:koror a hw:Host }

Returns true or false.

CONSTRUCT: Build New Triples

PREFIX hw: <http://example.org/homelab/>

CONSTRUCT {
  ?svc hw:colocatedWith ?other .
}
WHERE {
  ?svc hw:runsOn ?host .
  ?other hw:runsOn ?host .
  FILTER(?svc != ?other)
}

Returns triples showing which services share a host.

DESCRIBE: Entity Details

PREFIX hw: <http://example.org/homelab/>

DESCRIBE hw:koror

Returns all triples where koror is the subject.

Cheat Sheet

PatternMeaning
?x a hw:Host?x has type Host
FILTER(?n > 5)Numeric comparison
FILTER(CONTAINS(?s, "abc"))Substring match
OPTIONAL { ... }Include if available
{ A } UNION { B }Either pattern
GROUP BY ?xAggregate per group
ORDER BY DESC(?n)Sort descending
LIMIT 10 OFFSET 5Paginate
?x hw:a/hw:b ?yPath sequence
?x hw:a+ ?yTransitive closure
?x ^hw:a ?yReverse edge
?x (hw:a|hw:b) ?yEither predicate

What’s Next