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: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:korimako8
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:korimako32768
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:korimako32768
hw:palau16384

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:korimako3
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:korimako3
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.

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:traefikhw:korimako
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 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

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