The mental model
You write ordinary SHACL, as if your graph were RDF. shacl2cypher translates every term the shapes mention into an element of your property graph:
| In the shapes | In the property graph | Default name |
|---|---|---|
Class ex:Person | Label / node table | :Person, the local name |
Predicate with literal values ex:name | Property | name, the local name |
Predicate with node values ex:worksFor | Relationship, outgoing | WORKS_FOR, UPPER_SNAKE_CASE |
| A node (IRI) | A node, identified by a key property | --node-key or s2c:key |
| (no RDF equivalent) | Properties on a relationship | s2c:targetRelationship |
The resolution order is always the same: explicit s2c: annotations win, then structural hints and schema-snapshot evidence, then naming convention. --strict rejects anything that was decided by convention alone.
Labels and class hierarchies
A class target or sh:class becomes a label check. rdfs:subClassOf triples from the shapes files and from --ontology files are expanded transitively, so a shape targeting ex:Person also checks :Employee nodes when ex:Employee rdfs:subClassOf ex:Person.
- Neo4j: with
--neo4j-labels explicit(default) the query matches(n:Person|Employee). If your nodes already carry every superclass label, pass--neo4j-labels inheritedand the query matches only:Person. - LadybugDB: a node lives in exactly one table, so expansion always happens.
Rename a label with s2c:label on the class or on a node shape with a single class target:
ex:Person s2c:label "Human" .
Property or relationship?
This is the one decision that matters most, because the same predicate could be either. A path step is a relationship when any of these holds:
- It is annotated with
s2c:relationshipors2c:direction. - Its position must yield nodes: an inverse step, a repeated step (
*,+,?), or any step before the last in a sequence. - Its property shape has
sh:class,sh:node, orsh:nodeKindofsh:IRI,sh:BlankNodeorsh:BlankNodeOrIRI. - The schema snapshot declares the conventional relationship type and no focus label declares a property with that name.
Otherwise it is a property. A property declared in the snapshot always beats a structural hint, so a hint can never turn a real property into an always-empty relationship.
The common surprise. sh:path ex:manager ; sh:minCount 1 with no hint and no schema checks a property called manager. If manager is a relationship in your graph, add sh:class ex:Person, sh:nodeKind sh:IRI, or ex:manager s2c:relationship "MANAGER". Or compile with a schema snapshot, and the evidence decides.
Direction
Relationships are outgoing by default. sh:inversePath flips a step. If your RDF vocabulary names the reverse direction, keep the vocabulary and annotate:
# "Company employs Person", stored as (:Person)-[:WORKS_FOR]->(:Company)
ex:employs s2c:relationship "WORKS_FOR" ;
s2c:direction "in" .
Schema snapshots
A snapshot is a JSON description of node types, relationship types with endpoints, and typed properties. Get one with shacl2cypher schema dump. It does three jobs:
- Disambiguation. It settles whether a path is a property or a relationship (rule 4 above).
- Static diagnostics. A label, property or relationship type the shapes mention but the snapshot lacks is reported as
s2c:SchemaMismatchwith its file and line.--fail-on-schema-mismatchturns these into a compile error for CI. - Guarantees. On LadybugDB the schema is enforced, so a
sh:datatype xsd:stringon aSTRINGcolumn, or ash:classon a relationship whose rel table only reaches that class, needs no query. Those rules getstatus: guaranteed-by-schema.
On Neo4j the snapshot is optional and only an observation of the data, so it never marks a rule as guaranteed. On LadybugDB it is required; validate --ladybug reads it from the file for you.
Node identity and IRI constants
Result rows identify the focus node by a key: the shape's s2c:key, else --node-key, always alongside the database element id.
ex:CarShape s2c:key "vin" .
IRIs used as constants in sh:in and sh:hasValue are compared by local name against properties (ex:Active matches the string "Active"), or as full IRIs with s2c:iriAsString "full". Against relationships, they match the end node's key: sh:hasValue ex:acme on ex:worksFor means "works for the node whose key is acme". This needs --node-key.
Lists and missing values
Every path yields a set of values:
- A scalar property is one value. A list property is one value per element, and nulls inside lists are dropped.
null, an absent property and an empty list are all the empty set.- Value constraints (
sh:datatype, ranges, lengths, patterns,sh:in,sh:class) are satisfied by an empty set. Onlysh:minCountandsh:hasValuenotice that something is missing.
To treat a list as one value, for example to check sh:datatype against a LIST<STRING> column, use s2c:collection "scalar" together with s2c:datatype "LIST<STRING>".
Recipes
Validate properties on relationships
RDF has no properties on edges, so SHACL has no target for them. s2c:targetRelationship makes every relationship of a type a focus:
ex:KnowsShape s2c:targetRelationship "KNOWS" ;
sh:property [ sh:path ex:since ; sh:datatype xsd:date ; sh:maxCount 1 ] .
Only value constraints on single-property paths apply there. Rows carry a relationship focus {type, startKey, endKey, elementId}.
The RDF name and the graph name differ
ex:fullName s2c:property "name" .
ex:employer s2c:relationship "WORKS_FOR" .
ex:Human s2c:label "Person" .
Put the annotation on the predicate to apply it everywhere, or on one property shape to apply it only there. Shape-level annotations work for simple paths only.
Exact temporal types
xsd:dateTime accepts both zoned and local date-times. To demand one:
[ sh:path ex:createdAt ; sh:datatype xsd:dateTime ; s2c:datatype "ZONED_DATETIME" ]
Layer project-specific hints over shared shapes
All input files are unioned into one shapes graph. You can keep a clean, portable shapes file and add a second file with the s2c: annotations, deactivations and extra shapes that only your property graph needs:
shacl2cypher validate domain.shacl.ttl domain.lpg-overlay.ttl --connect bolt://localhost:7687
Settings that may only have one value, such as two different sh:severity values on one shape, are compile errors that list both locations. The LPG Modeler guide uses this pattern for generated shapes.
Roll out a rule gradually
Start new constraints at sh:severity sh:Warning and validate with the default --fail-on violation: warnings show up in the report but do not fail the build. Raise the severity once the data is clean. Use sh:deactivated true to park a rule; it stays visible in the manifest as deactivated.
Make every mapping reviewable
Compile with --strict and a schema snapshot. Every name must then be annotated or confirmed by the schema, so a typo like ex:emial is a compile error instead of a rule that silently finds nothing.
Where results differ from an RDF SHACL engine
sh:closedalso forbids undeclared outgoing relationships, and always allows the node key. Incoming relationships are not constrained.sh:minLengthandsh:maxLengthapply to strings and integers only.- Node values are IRIs and property values are literals for
sh:nodeKind; blank nodes never occur. - Rows for nested shapes include
details: the inner rule ids that failed. That goes beyond the standard report.
Not supported
These are rejected at compile time, or recorded as unsupported rules with --lenient:
sh:targetNode: there are no IRIs to name.- SHACL-SPARQL and SPARQL-based constraint components.
sh:languageIn,sh:uniqueLangandrdf:langString: property graphs have no language tags.- Recursive shapes, because nested shapes are inlined into the query.
- Property shapes nested inside property shapes (not yet).
Dialect notes
- LadybugDB table names are case-insensitive.
ex:Address(labelAddress) andex:address(relationshipADDRESS) collide. Rename one withs2c:labelors2c:relationship. - LadybugDB rejects regex back-references and caps variable-length paths at 30 hops, so
--max-path-depthabove 30 is an error there. - Neo4j decodes
\uXXXXeven inside backticks, so an identifier containing that text cannot be rendered and fails compilation instead of matching the wrong name. - Patterns are parsed as XSD regular expressions and rewritten for each engine. They are never passed through verbatim, and an invalid pattern is a compile error at its source line.