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:korimako a hw:Host ;
rdfs:label "korimako" ;
hw:hostname "korimako.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:korimako ;
hw:port "443"^^xsd:integer ;
hw:dependsOn hw:pihole .
hw:pihole a hw:Service ;
rdfs:label "pihole" ;
hw:runsOn hw:korimako ;
hw:port "53"^^xsd:integer .
hw:grafana a hw:WebApp ;
rdfs:label "grafana" ;
hw:runsOn hw:korimako ;
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/korimako |
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:korimako | 8 |
hw:palau | 4 |
hw:yap | 4 |
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:korimako | 32768 |
hw:palau | 16384 |
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:traefik | hw:pihole |
hw:pihole | |
hw:grafana | hw:prometheus |
hw:prometheus | |
hw:minio | |
hw:nginx | hw: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:korimako | 32768 |
hw:palau | 16384 |
OFFSET 1 LIMIT 1 would skip korimako and return only palau.
An explicit ORDER BY … LIMIT k OFFSET n retains the best k + n
solutions when only column projection separates the limit from the sort and
keys are constants or consistently typed variables. Mixed incomparable values,
non-finite numbers, and computed keys retain the full-sort path.
Response-provenance columns also keep the full relation until projection, so
metadata present only outside the requested page cannot disappear from headers.
It still evaluates the complete input: this optimization does not push a
scan limit through filters, joins, DISTINCT, or aggregates. Equal sort keys
preserve input order, and each sort expression is evaluated once per solution.
Native complete sorts above 4,096 input rows use private temporary-file runs; smaller sorts retain the in-memory path. The existing 65,536-key-entry and 8 MiB retained-key guards are unchanged. Spill runs use a 2 MiB key budget, at most 128 initial runs, a 1 MiB encoded-record limit, and a cumulative 256 MiB write quota including merge passes. Files contain evaluated keys and row positions, are private to the process, and are removed when closed. Disk errors, quota exhaustion, oversized keys, and deadlines return errors; they never return a silently shortened sorted result. WebAssembly keeps the explicit in-memory refusal because it has no native temporary-file backend.
These limits bound sorting keys and temporary storage. Earlier query operators
may already have materialized the input rows, so they are not a bound on total
query memory. A small LIMIT also cannot make an expensive input scan cheap.
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:korimako | 3 |
hw:palau | 2 |
hw:yap | 1 |
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 |
|---|---|
| 16 | 57344 |
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:korimako | 3 |
hw:palau | 2 |
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.
| Question | Form |
|---|---|
| “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.
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": true, "inference": {...}}
If hw:postgres is typed as hw:DatabaseService and that class is a subclass
of hw:Service, the constant-type ASK includes it through subclass expansion.
The inference marker identifies that expansion. CONSTRUCT and DESCRIBE
results also carry the marker when their query patterns expand.
What the marker claims. It reports the type expansion applied to this query. A marked ASK can also match a directly asserted fact; the marker alone cannot prove how a particular row matched. 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: applied: http://example.org/homelab/Service
The header is absent when no type expansion was applied. 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 expandedTypes detail is
available in the default JSON response.
Standard HTTP responses also carry X-Quipu-Truncated: true when the server
row ceiling removed results, or false when the result is complete. This
header applies to SPARQL JSON/XML, CSV/TSV and RDF graph output; browser clients
can read it through CORS. Check it before treating a result as a complete
answer. A result exactly at the ceiling is complete if no extra row was removed.
The default JSON format continues to report its existing truncated field.
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:traefik | hw:korimako |
hw:grafana | hw:palau |
hw:nginx | hw: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 korimako host?” using reverse traversal:
PREFIX hw: <http://example.org/homelab/>
SELECT ?svc
WHERE {
hw:korimako ^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:korimako 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:korimako
Returns all triples where korimako is the subject.
Cheat Sheet
| Pattern | Meaning |
|---|---|
?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 ?x | Aggregate per group |
ORDER BY DESC(?n) | Sort descending |
LIMIT 10 OFFSET 5 | Paginate |
?x hw:a/hw:b ?y | Path sequence |
?x hw:a+ ?y | Transitive closure |
?x ^hw:a ?y | Reverse edge |
?x (hw:a|hw:b) ?y | Either predicate |
What’s Next
- Homelab Operator Tutorial — model a full infrastructure
- Temporal Model — deep dive on time-travel
- REST API Reference — every endpoint