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

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

Fluree decides whether to run SHACL validation on each transaction using this order:

  1. If a config graph exists with f:shaclDefaults — follow the configured settings per graph (enable/disable, mode).
  2. 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 Reject mode. 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.

Bulk import is deliberately exempt. The bulk-import pipeline never runs SHACL — it is a trusted, high-throughput load path. If your source data must conform, validate it before importing — fluree validate source.ttl --shacl shapes.ttl produces a full report (see Validation reports) — so the ledger starts clean; transaction-time validation keeps it clean from there.

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:shaclEnabled defaults to false when a f:shaclDefaults section exists without it — make the enable decision explicit.
  • f:validationMode defaults to f:ValidationReject. Use f:ValidationWarn to 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

TargetEffect
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>
implicit (shape a rdfs:Class)A shape that is also a class targets its own instances — no explicit target needed

See Predicate-target shapes for notes on how the staged-path validator discovers focus nodes for sh:targetSubjectsOf / sh:targetObjectsOf.

Property paths

sh:path is usually a single predicate, but it can also be a property path expression. The path is evaluated against the focus node to produce the set of value nodes the constraints then apply to — so sh:minCount, sh:datatype, sh:class, etc. all work over a path exactly as they do over a plain predicate.

Path formTurtle syntaxReaches
Predicatesh:path ex:knowsobjects of ex:knows
Inversesh:path [ sh:inversePath ex:parent ]subjects that point at the focus via ex:parent (works over any path: [ sh:inversePath ( ex:a ex:b ) ] reaches nodes two hops upstream)
Sequencesh:path ( ex:knows schema:name )names of the people the focus knows
Alternativesh:path [ sh:alternativePath ( ex:email ex:altEmail ) ]values via either predicate
Zero-or-moresh:path [ sh:zeroOrMorePath ex:parent ]the focus and all transitive ex:parent ancestors
One-or-moresh:path [ sh:oneOrMorePath ex:parent ]all transitive ex:parent ancestors (excludes the focus)
Zero-or-onesh:path [ sh:zeroOrOnePath ex:parent ]the focus and its direct parents

These nest: sh:path ( [ sh:inversePath ex:parent ] schema:name ) reaches the names of the focus’s children.

# Every Parent must have at least one child (something points at it via ex:parent),
# and each place a Person knows-of must be named.
ex:ParentShape a sh:NodeShape ;
  sh:targetClass ex:Parent ;
  sh:property [
    sh:path [ sh:inversePath ex:parent ] ;
    sh:minCount 1 ;
    sh:message "A Parent must have at least one child"
  ] .

ex:SocialiteShape a sh:NodeShape ;
  sh:targetClass ex:Socialite ;
  sh:property [
    sh:path ( ex:knows schema:name ) ;
    sh:datatype xsd:string ;
    sh:minCount 1
  ] .

In JSON-LD, a sequence path is written with @list, and the blank-node forms are written as nested objects:

{
  "@id": "ex:SocialiteShape",
  "@type": "sh:NodeShape",
  "sh:targetClass": { "@id": "ex:Socialite" },
  "sh:property": [{
    "sh:path": { "@list": [ { "@id": "ex:knows" }, { "@id": "schema:name" } ] },
    "sh:datatype": { "@id": "xsd:string" },
    "sh:minCount": 1
  }, {
    "sh:path": { "sh:inversePath": { "@id": "ex:parent" } },
    "sh:minCount": 1
  }]
}

Not supported: the inverse of a composite path (e.g. [ sh:inversePath ( ex:a ex:b ) ]). sh:inversePath may only wrap a single predicate. An unsupported or unresolvable path doesn’t silently pass — it produces a violation whenever the owning shape validates a focus node, so any transaction touching data that shape targets is rejected with a clear message. A broken path only affects the shape’s own targets, not unrelated writes.

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).

Language constraints

sh:languageIn restricts values to language-tagged literals whose tag matches one of the given basic language ranges ("en" also matches "en-US", per SPARQL langMatches); untagged values violate. sh:uniqueLang true forbids two values of the property from sharing a language tag.

ex:LabelShape a sh:NodeShape ;
  sh:targetClass ex:Labeled ;
  sh:property [
    sh:path ex:label ;
    sh:languageIn ( "en" "fr" ) ;
    sh:uniqueLang true
  ] .

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
  ] .
ConstraintSemantic
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.

Shape-based constraints (sh:node)

sh:node validates a node against another node shape. On a property shape it applies to each value; directly on a node shape it applies to the focus node itself. The referenced shape is usually targetless — it fires only where it is referenced.

ex:AddressShape a sh:NodeShape ;
  sh:property [ sh:path ex:postalCode ; sh:minCount 1 ] .

ex:PersonShape a sh:NodeShape ;
  sh:targetClass ex:Person ;
  sh:property [
    sh:path ex:address ;
    sh:node ex:AddressShape
  ] .

Recursive references are safe: a shape may reference itself (directly or via a chain), and validation over cyclic data (e.g. a mutual ex:knows graph) terminates — a node already being validated against a shape higher in the evaluation is assumed conforming, matching common SHACL engine behavior.

Qualified value shapes

sh:qualifiedValueShape counts how many values conform to a shape and checks the count against sh:qualifiedMinCount / sh:qualifiedMaxCount — unlike sh:node, values that don’t conform are fine as long as enough do.

ex:TeamShape a sh:NodeShape ;
  sh:targetClass ex:Team ;
  sh:property [
    sh:path ex:member ;
    sh:qualifiedValueShape ex:BadgedMemberShape ;
    sh:qualifiedMinCount 1
  ] .

sh:qualifiedValueShapesDisjoint true additionally excludes values that conform to a sibling qualified shape — e.g. a crew needing one pilot and one navigator as distinct members rejects a single member holding both roles.

Constraints on the node itself

Value constraints declared directly on a node shape (without sh:path) apply to the focus node. Combined with a predicate target this restricts which nodes may appear in a position:

# Only ex:active / ex:inactive may be used as an ex:status value.
ex:StatusShape a sh:NodeShape ;
  sh:targetObjectsOf ex:status ;
  sh:in ( ex:active ex:inactive ) .

Deactivating a shape

sh:deactivated true turns a shape off without deleting it — it stops firing for its targets and is treated as conforming when referenced via sh:node or logical constraints.

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). Per the SHACL spec, rdf:type is not implicitly ignored — a closed shape with sh:targetClass (whose instances necessarily carry rdf:type) must list it in sh:ignoredProperties, as above.

RDFS entailment in enforcement

SHACL enforcement applies RDFS subclass and subproperty inference always — no configuration needed:

  • Targets: sh:targetClass ex:Employee also fires for instances of any rdfs:subClassOf* ex:Employee class; sh:targetSubjectsOf / sh:targetObjectsOf ex:phone also match subjects/objects of any rdfs:subPropertyOf* ex:phone property.
  • Paths: a constraint on sh:path schema:name also governs values asserted via any subproperty of schema:name (including through sequence/inverse/alternative path steps and pair constraints).

The hierarchy used is the committed state, kept current automatically: commits that assert or retract rdfs:subClassOf / rdfs:subPropertyOf invalidate a shared cache that rebuilds lazily; all other commits pay nothing. One consequence worth knowing: schema asserted in the same transaction as data does not entail for that transaction — commit the schema first, then the data (two transactions).

Policy enforcement applies the same inference: f:onClass policies govern subclass instances and f:onProperty policies govern subproperties — see the policy cookbook.

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:

  1. 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.
  2. 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:subClassOf via a BFS on the database’s SPOT index. This walk is scoped to the default graph regardless of the subject’s own graph — matching how SchemaHierarchy is 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") makes sh:targetSubjectsOf(ex:ssn) fire on alice even when this transaction only retracts ex: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:scratch passes without validation (graph disabled).
  • A violating write to ex:audit passes but emits a tracing::warn! (Warn mode).
  • 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-widePer-graphEffective
enabled: false, OverrideNoneenabled: truedisabled (OverrideNone blocks per-graph)
enabled: true, OverrideAllenabled: falsedisabled for that graph
mode: warn, OverrideAllmode: rejectreject 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:shapesSource is authoritative, not additive: when set, shapes come exclusively from the configured graph. Shapes in the default graph are ignored.
  • f:shapesSource is non-overridable — it can only be set in the config graph, not via transaction/query-time options.
  • Use f:graphSelector f:defaultGraph to explicitly point at the default graph (same as omitting f:shapesSource).
  • f:shapesSource also supports cross-ledger references — set f:ledger on the inner f:graphSource to compile shapes from a different ledger at validation time. See Cross-ledger governance — Cross-ledger SHACL shapes for the end-to-end pattern.

Shared value-sets with sh:class

sh:class is the natural way to model a controlled value-set — e.g. a fixed list of US states — as an extensible enumeration: each allowed value is an instance of a class, and adding a new value means inserting one triple rather than editing the shape (contrast sh:in, which bakes the list into the shape). Put the value-set vocabulary in the same graph as the shapes (f:shapesSource), and it is honoured even when the referencing records live in a different graph:

@prefix f:   <https://ns.flur.ee/db#> .
@prefix sh:  <http://www.w3.org/ns/shacl#> .
@prefix rdf: <http://www.w3.org/1999/02/22-rdf-syntax-ns#> .
@prefix ex:  <http://example.org/> .

GRAPH <http://example.org/shapes> {
  ex:PersonShape a sh:NodeShape ;
    sh:targetClass ex:Person ;
    sh:property [ sh:path ex:homeState ; sh:class ex:USState ] .

  # The value-set vocabulary lives alongside the shapes.
  ex:illinois a ex:USState .
  ex:iowa     a ex:USState .
}

With f:shapesSource pointing at <http://example.org/shapes>, a record written to any graph passes validation when its ex:homeState is one of the declared states, and is rejected otherwise. Adding a state later (ex:ohio a ex:USState) requires no shape change.

Semantics and limits:

  • Membership graph = focus data graph ∪ the f:shapesSource graph. A value is an instance of the class if it is typed so in either the record’s own graph or the vocabulary graph. When shapes live in the default graph, this is just an ordinary local lookup.
  • Per-transaction caching. Repeated references to the same value within one transaction are memoized — bulk-inserting many records that share a state pays the membership lookup once.
  • Cross-ledger too. When f:shapesSource is cross-ledger (f:ledger), the controlled vocabulary lives in the model ledger alongside the shapes, and membership is resolved by querying that model ledger live (pinned at its latest committed state at transaction time). So a shared governance model can hold shapes and the value-sets they reference, and many data ledgers can point at it.

Inline shapes per transaction

In addition to shapes stored in a ledger, a transaction can supply inline shapes via the opts.shapes field. The shapes are enforced only for that one transaction and never written into the ledger.

{
  "@context": {"ex": "http://example.org/ns/"},
  "@id":   "ex:alice",
  "@type": "ex:Person",
  "opts": {
    "shapes": {
      "@context": {
        "ex":  "http://example.org/ns/",
        "sh":  "http://www.w3.org/ns/shacl#",
        "xsd": "http://www.w3.org/2001/XMLSchema#"
      },
      "@graph": [
        {
          "@id":            "ex:PersonShape",
          "@type":          "sh:NodeShape",
          "sh:targetClass": {"@id": "ex:Person"},
          "sh:property":    {"@id": "ex:pshape_name"}
        },
        {
          "@id":         "ex:pshape_name",
          "sh:path":     {"@id": "ex:name"},
          "sh:minCount": 1,
          "sh:datatype": {"@id": "xsd:string"}
        }
      ]
    }
  }
}

Semantics:

  • Additive with the configured source. Inline shapes enforce alongside whatever f:shapesSource resolves to. A subject must satisfy every shape from both sources. Note that f:shapesSource is itself singular — its f:graphSource is either local (no f:ledger) or cross-ledger (with f:ledger), not both at the same time. Inline shapes don’t change that; they layer on top of whichever variant is configured.
  • Transient. The shapes never appear in the ledger’s data and vanish after the transaction completes. The next transaction without opts.shapes runs without them.
  • Gated by config. If f:shaclEnabled false (or no graph is enabled), inline shapes do not bypass that posture — operator config wins. To use inline shapes on a fresh ledger with no config, the shapes-exist heuristic enables validation automatically.
  • No audit trail. Because inline shapes don’t persist, it isn’t possible to reconstruct “which shapes validated which commit” from ledger history. If auditability matters, store shapes in a f:shapesSource graph instead.

Use cases that fit well: ad-hoc shape testing before committing to f:shapesSource, per-tenant validation layers in an application server, request-scoped governance that should not become part of permanent ledger state.

Validation modes

  • f:ValidationReject (default): on any violation, the transaction fails with ShaclViolation(report). The formatted report lists each violation’s focus node, property path, and message.
  • f:ValidationWarn: violations are logged via tracing::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.

Validation reports (fluree validate)

Transaction-time enforcement rejects (or warns about) new writes. fluree validate answers the complementary questions: is my existing data clean? and is this source file clean before I import it? It produces a W3C-shaped sh:ValidationReport instead of an error:

fluree validate mydb                            # ledger vs its attached shapes
fluree validate mydb --shacl proposed.ttl       # trial stricter shapes (replaces attached)
fluree validate data.ttl --shacl shapes.ttl     # standalone file, ephemeral in-memory ledger
fluree validate data.ttl --format turtle        # W3C report as Turtle (also: jsonld)

Ad-hoc shapes (--shacl) replace the attached shapes by default so “does this data conform to these rules?” is answered exactly; --include-attached unions both sets. Exit codes make it CI-friendly: 0 = conforms, 1 = findings at/above --fail-on (default violation). See the fluree validate reference.

The same core is served over HTTP as GET|POST /validate/{ledger} (JSON summary by default; Accept: application/ld+json or text/turtle for the W3C report), and exposed in Rust as fluree_db_api::validateFluree::validate_ledger(alias, &ValidateOptions) returns a ValidateReport with per-result constraint-component IRIs, severities, and messages, plus to_jsonld() / to_turtle() serializers.

Not yet supported

  • sh:sparql (SPARQL-based constraints).

These are tracked in the SHACL compliance effort.

Shapes are data

Because shapes live as regular RDF in your ledger:

  • Time-travelable@atT query any shape’s history to see what validation was in effect at a given commit.
  • Versionabledelete/insert constraints through ordinary transactions.
  • QueryableSELECT ?shape ?target WHERE { ?shape sh:targetClass ?target }.
  • Branchable — test new constraints on a branch; merge when verified.

Best practices

  1. Start with sh:minCount — missing-value bugs are the most common data quality issue.
  2. Incremental rollout — deploy shapes in f:ValidationWarn mode first. Watch the logs for a sprint, then flip to f:ValidationReject.
  3. Per-graph scratch zones — for experimentation, disable SHACL on a named graph so exploratory transactions don’t fail your CI.
  4. sh:message everywhere — custom messages are what end users see when a transaction is rejected. Invest in them early.
  5. f:shapesSource for schema hygiene — keep shapes out of user data graphs so deletes / retractions on user data can’t accidentally touch your schema.