Cookbook: SHACL Validation
SHACL (Shapes Constraint Language) is a W3C standard for defining constraints on graph data. In Fluree, SHACL shapes are evaluated at transaction time — invalid data is rejected before it's committed (or logged as a warning, depending on your config).
This guide covers:
- When SHACL runs — with and without a config graph
- Enabling SHACL via the config graph
- Defining shapes — node shapes, property shapes, targets
- Constraint patterns — cardinality, datatype, ranges, patterns, values, class, pair, logical
- Subclass reasoning for
sh:class - Predicate-target shapes —
sh:targetSubjectsOf/sh:targetObjectsOf - Per-graph enable/disable and warn vs reject modes
- Storing shapes in a named graph with
f:shapesSource - What isn't enforced yet
When SHACL runs
Fluree decides whether to run SHACL validation on each transaction using this order:
- If a config graph exists with
f:shaclDefaults— follow the configured settings per graph (enable/disable, mode). - If no config graph section is present — fall back to the shapes-exist heuristic: if any SHACL shapes are present in the database (as regular RDF triples), validation runs in
Rejectmode. If no shapes are present, validation is skipped entirely (zero overhead).
This means you can start using SHACL without writing any config — just transact shapes and they're enforced.
The shacl feature must be enabled at build time (it's on by default for the server and CLI binaries). See Standards and feature flags.
Enabling SHACL via the config graph
Writing ledger config is done via transactions into the config graph, whose IRI is always urn:fluree:{ledger_id}#config. See Writing config data for the full pattern.
Minimal config: enable SHACL, shapes in the default graph
@prefix f: <https://ns.flur.ee/db#> .
GRAPH <urn:fluree:mydb:main#config> {
<urn:config:main> a f:LedgerConfig ;
f:shaclDefaults [
f:shaclEnabled true ;
f:validationMode f:ValidationReject
] .
}
Notes:
f:shaclEnableddefaults tofalsewhen af:shaclDefaultssection exists without it — make the enable decision explicit.f:validationModedefaults tof:ValidationReject. Usef:ValidationWarnto log violations without failing the transaction.- With no explicit
f:shapesSource, shapes are compiled from the default graph (f:defaultGraph, g_id=0). See Storing shapes in a named graph to load from elsewhere.
Defining shapes
Shapes are ordinary RDF — transact them like any other data. They can be written in Turtle, TriG, or JSON-LD.
Node shape with property constraints
@prefix sh: <http://www.w3.org/ns/shacl#> .
@prefix schema: <http://schema.org/> .
@prefix ex: <http://example.org/> .
@prefix xsd: <http://www.w3.org/2001/XMLSchema#> .
ex:PersonShape a sh:NodeShape ;
sh:targetClass schema:Person ;
sh:property [
sh:path schema:name ;
sh:datatype xsd:string ;
sh:minCount 1 ;
sh:maxCount 1 ;
sh:message "Every person must have exactly one name"
] ;
sh:property [
sh:path schema:email ;
sh:datatype xsd:string ;
sh:pattern "^[^@]+@[^@]+\\.[^@]+$" ;
sh:message "Email must be a valid email address"
] ;
sh:property [
sh:path ex:age ;
sh:datatype xsd:integer ;
sh:minInclusive 0 ;
sh:maxInclusive 200
] .
Target types
| Target | Effect |
|---|---|
sh:targetClass <C> | Every subject with rdf:type <C> (including RDFS subclasses of <C> when the hierarchy is available) |
sh:targetNode <N> | The specific subject <N> |
sh:targetSubjectsOf <P> | Every subject that currently has predicate <P> |
sh:targetObjectsOf <P> | Every node that currently appears as the object of <P> |
See Predicate-target shapes for notes on how the staged-path validator discovers focus nodes for sh:targetSubjectsOf / sh:targetObjectsOf.
Constraint patterns
Cardinality — required and multi-valued
ex:ArticleShape a sh:NodeShape ;
sh:targetClass ex:Article ;
sh:property [ sh:path ex:title ; sh:minCount 1 ; sh:maxCount 1 ] ;
sh:property [ sh:path ex:tag ; sh:minCount 1 ] .
Datatype
ex:ProductShape a sh:NodeShape ;
sh:targetClass ex:Product ;
sh:property [ sh:path ex:price ; sh:datatype xsd:decimal ] ;
sh:property [ sh:path ex:inStock ; sh:datatype xsd:boolean ] .
Numeric ranges
ex:OrderShape a sh:NodeShape ;
sh:targetClass ex:Order ;
sh:property [
sh:path ex:quantity ;
sh:datatype xsd:integer ;
sh:minInclusive 1 ;
sh:maxInclusive 10000
] .
Available: sh:minInclusive, sh:maxInclusive, sh:minExclusive, sh:maxExclusive.
String patterns and length
ex:UserShape a sh:NodeShape ;
sh:targetClass ex:User ;
sh:property [
sh:path ex:username ;
sh:datatype xsd:string ;
sh:minLength 3 ;
sh:maxLength 32 ;
sh:pattern "^[a-zA-Z0-9_]+$"
] .
sh:pattern accepts an optional sh:flags string (e.g. "i" for case-insensitive).
Node kind
ex:RefShape sh:property [
sh:path ex:owner ;
sh:nodeKind sh:IRI
] .
Values: sh:IRI, sh:BlankNode, sh:Literal, sh:BlankNodeOrIRI, sh:BlankNodeOrLiteral, sh:IRIOrLiteral.
Enumerated values
ex:TaskShape a sh:NodeShape ;
sh:targetClass ex:Task ;
sh:property [
sh:path ex:status ;
sh:in ( "todo" "in-progress" "review" "done" )
] .
sh:hasValue requires a specific value to be present.
Class constraint (with RDFS subclass reasoning)
ex:OrderShape a sh:NodeShape ;
sh:targetClass ex:Order ;
sh:property [
sh:path ex:customer ;
sh:class schema:Person ;
sh:minCount 1
] .
Each value of ex:customer must have rdf:type schema:Person — or rdf:type of any class that is rdfs:subClassOf* schema:Person. See RDFS subclass reasoning for sh:class.
Pair constraints — comparing two properties
ex:EventShape a sh:NodeShape ;
sh:targetClass ex:Event ;
sh:property [
sh:path ex:startYear ;
sh:lessThan ex:endYear
] ;
sh:property [
sh:path ex:primaryEmail ;
sh:disjoint ex:secondaryEmail
] .
| Constraint | Semantic |
|---|---|
sh:equals <P> | Value sets for this path and <P> must be identical |
sh:disjoint <P> | Value sets must not overlap |
sh:lessThan <P> | Every value on this path must be strictly less than every value of <P> |
sh:lessThanOrEquals <P> | Every value on this path must be ≤ every value of <P> |
Logical constraints
ex:ContactShape a sh:NodeShape ;
sh:targetClass ex:Contact ;
sh:or (
[ sh:property [ sh:path schema:email ; sh:minCount 1 ] ]
[ sh:property [ sh:path schema:telephone ; sh:minCount 1 ] ]
) .
Available: sh:not, sh:and, sh:or, sh:xone.
Closed shapes
ex:StrictPersonShape a sh:NodeShape ;
sh:targetClass ex:StrictPerson ;
sh:closed true ;
sh:ignoredProperties ( rdf:type ) ;
sh:property [ sh:path schema:name ; sh:minCount 1 ] .
A closed shape forbids any property not explicitly declared (or listed in sh:ignoredProperties). rdf:type is implicitly ignored per the SHACL spec.
RDFS subclass reasoning for sh:class
sh:class honors rdfs:subClassOf. Example:
ex:Novelist rdfs:subClassOf schema:Person .
ex:pratchett rdf:type ex:Novelist .
ex:BookShape sh:property [
sh:path ex:author ;
sh:class schema:Person
] .
A book whose ex:author is ex:pratchett conforms — ex:pratchett is a schema:Person via rdfs:subClassOf.
Fluree resolves this in two tiers:
- Fast path: the ledger's indexed schema hierarchy (
SchemaHierarchy). Expanded at engine build time so same-class and descendant-class matches are O(1) hashmap hits. - Live fallback: when the subclass relation was asserted in the current transaction (or any earlier unindexed commit), the fast path misses. The engine then walks
rdfs:subClassOfvia a BFS on the database's SPOT index. This walk is scoped to the default graph regardless of the subject's own graph — matching howSchemaHierarchyis built and preventing cross-graph issues.
Predicate-target shapes
sh:targetSubjectsOf(P) and sh:targetObjectsOf(P) depend on the current state of the database — a subject is a focus node iff it actually has (or is referenced by) predicate P in the post-transaction view.
Fluree does not precompute target hints from staged flakes. Instead, for each focus node being validated, the engine does a bounded existence check against the post-state:
sh:targetSubjectsOf(P)→ SPOT range query(focus, P, _). Non-empty → shape applies.sh:targetObjectsOf(P)→ OPST range query(_, P, focus). Non-empty → shape applies.
This means:
- A base-state
(alice, ex:ssn, "123")makessh:targetSubjectsOf(ex:ssn)fire on alice even when this transaction only retractsex:name. - A retraction-only transaction that removes the last matching edge means the shape no longer applies — the post-state check returns empty.
- The check is bounded by the number of predicate-targeted shapes in the cache, not the data size.
Ref-objects of asserted flakes are pulled into the focus set for their graph, so newly-introduced inbound edges trigger validation of the referenced node.
Per-graph configuration
Each named graph can have its own f:shaclEnabled and f:validationMode via f:graphOverrides:
@prefix f: <https://ns.flur.ee/db#> .
@prefix ex: <http://example.org/> .
GRAPH <urn:fluree:mydb:main#config> {
<urn:config:main> a f:LedgerConfig ;
# Ledger-wide: SHACL on, reject on violation.
f:shaclDefaults [
f:shaclEnabled true ;
f:validationMode f:ValidationReject ;
f:overrideControl f:OverrideAll
] ;
# Per-graph: ex:scratch has SHACL off; ex:audit uses warn mode.
f:graphOverrides
[ a f:GraphConfig ;
f:targetGraph ex:scratch ;
f:shaclDefaults [ f:shaclEnabled false ]
],
[ a f:GraphConfig ;
f:targetGraph ex:audit ;
f:shaclDefaults [ f:validationMode f:ValidationWarn ]
] .
}
With this config:
- A violating write to the default graph is rejected (ledger-wide
Reject). - A violating write to
ex:scratchpasses without validation (graph disabled). - A violating write to
ex:auditpasses but emits atracing::warn!(Warnmode). - A single multi-graph transaction can mix modes: reject-bucket violations fail the txn; warn-bucket violations get logged.
Monotonicity
Per-graph configs can only tighten the ledger-wide posture:
| Ledger-wide | Per-graph | Effective |
|---|---|---|
enabled: false, OverrideNone | enabled: true | disabled (OverrideNone blocks per-graph) |
enabled: true, OverrideAll | enabled: false | disabled for that graph |
mode: warn, OverrideAll | mode: reject | reject for that graph |
See Override control for the full ruleset.
Storing shapes in a named graph
f:shapesSource points the shape compiler at a specific graph. Useful when you want schema / shapes isolated from data — even the config graph itself can be used as a shape source.
@prefix f: <https://ns.flur.ee/db#> .
GRAPH <urn:fluree:mydb:main#config> {
<urn:config:main> a f:LedgerConfig ;
f:shaclDefaults [
f:shaclEnabled true ;
f:shapesSource [
a f:GraphRef ;
f:graphSource [ f:graphSelector <http://example.org/shapes> ]
]
] .
}
Semantics:
f:shapesSourceis authoritative, not additive: when set, shapes come exclusively from the configured graph. Shapes in the default graph are ignored.f:shapesSourceis non-overridable — it can only be set in the config graph, not via transaction/query-time options.- Use
f:graphSelector f:defaultGraphto explicitly point at the default graph (same as omittingf:shapesSource).
Validation modes
f:ValidationReject(default): on any violation, the transaction fails withShaclViolation(report). The formatted report lists each violation's focus node, property path, and message.f:ValidationWarn: violations are logged viatracing::warn!and the transaction proceeds. Any non-violation error from the SHACL pipeline (compile failure, range-scan failure) still propagates — Warn mode never silently admits a broken validation pipeline.
Working with shapes across write surfaces
SHACL validation runs consistently on every write surface:
- JSON-LD / SPARQL transactions (
fluree insert,fluree upsert,fluree update) - Turtle / TriG ingest (
fluree insert-turtle,stage_turtle_insert) - Commit replay (
push_commits_with_handle, followers applying upstream commits)
All three routes go through the same post-stage helper, so the ledger's configured SHACL posture (enable/disable, mode, per-graph, shapes source) applies uniformly.
Not yet supported
The following SHACL constructs are parsed/compiled but currently no-ops at validation time. Shapes using them load without error but don't constrain data:
sh:uniqueLang,sh:languageIn— require language-tag metadata on flakes, which isn't yet threaded through the validation path.sh:qualifiedValueShape(+sh:qualifiedMinCount/sh:qualifiedMaxCount) — requires recursive nested-shape counting.
These are tracked in the SHACL compliance effort. Contributors: see Contributing / SHACL implementation.
Shapes are data
Because shapes live as regular RDF in your ledger:
- Time-travelable —
@atTquery any shape's history to see what validation was in effect at a given commit. - Versionable —
delete/insertconstraints through ordinary transactions. - Queryable —
SELECT ?shape ?target WHERE { ?shape sh:targetClass ?target }. - Branchable — test new constraints on a branch; merge when verified.
Best practices
- Start with
sh:minCount— missing-value bugs are the most common data quality issue. - Incremental rollout — deploy shapes in
f:ValidationWarnmode first. Watch the logs for a sprint, then flip tof:ValidationReject. - Per-graph scratch zones — for experimentation, disable SHACL on a named graph so exploratory transactions don't fail your CI.
sh:messageeverywhere — custom messages are what end users see when a transaction is rejected. Invest in them early.f:shapesSourcefor schema hygiene — keep shapes out of user data graphs so deletes / retractions on user data can't accidentally touch your schema.
Related documentation
- Setting Groups — SHACL — Configuration reference for
f:shaclDefaults - Override Control — Per-graph / query-time override rules
- Writing Config Data — How to transact into the config graph
- Contributing / SHACL implementation — How the pipeline works internally (for contributors)