Four patterns
ex:PersonShape a sh:NodeShape ;
sh:targetClass ex:Person ;
# Required and single-valued.
sh:property [ sh:path ex:name ; sh:minCount 1 ; sh:maxCount 1 ] ;
# Optional, but at most one value.
sh:property [ sh:path ex:email ; sh:maxCount 1 ] ;
# A list property: between one and three distinct skills.
sh:property [ sh:path ex:skills ; sh:minCount 1 ; sh:maxCount 3 ] ;
# A relationship: at most one employer.
sh:property [ sh:path ex:worksFor ; sh:class ex:Company ; sh:maxCount 1 ] .
| You mean | Write |
|---|---|
| required | sh:minCount 1 |
| optional, single-valued | sh:maxCount 1 |
| exactly one | sh:minCount 1 ; sh:maxCount 1 |
| between n and m | sh:minCount n ; sh:maxCount m |
| optional, any number | nothing: that is the default |
SHACL has no "required" keyword. Absence is just "fewer than one value".
What counts as a value
Every path gives each focus node a set of values. In a property graph that set is built like this:
- A scalar property is one value:
name: "Ann"→ {"Ann"}. - A list property is one value per element:
skills: ["go", "sql"]→ {"go", "sql"}. - A property that is absent,
null, or an empty list is the empty set. nullelements inside a list are dropped.- Counts are over distinct values:
["a", "a"]is one value. - For a relationship path, the values are the distinct nodes reached.
shacl2cypher's conformance suite pins this behaviour down with a fixture that runs against pySHACL, Neo4j and LadybugDB. For shapes name (min 1), tags (min 1, max 2) and codes (max 1):
| Node data | Violations |
|---|---|
{name: "Ann", tags: ["a","b"], codes: ["x"]} | none |
{} | name minCount, tags minCount |
{name: null, tags: []} | name minCount, tags minCount |
{name: "Bo", tags: ["a","b","c"]} | tags maxCount |
{name: "Cy", tags: ["a","a"], codes: ["x","x"]} | none: duplicates count once |
{name: "Di", tags: ["a"], codes: ["x","y"]} | codes maxCount |
Why sh:maxCount 1 matters on Neo4j
Neo4j allows any property to hold a list, and nothing stops an import script from writing email: ["a@x", "b@x"] where the application expects a string. sh:maxCount 1 catches it. On LadybugDB a STRING column can't hold a list, but the rule still documents intent and still guards relationships.
Treating a list as one value
If a property is a list as a whole, say coordinates [lat, lon], you may want sh:maxCount 1 to mean "one list". Annotate it:
ex:location s2c:collection "scalar" .
What a cardinality violation looks like
Counting constraints report once per focus node, not per value, so the row's value is null:
failed Violation 1 2 PersonShape.email.minCount
- Person id=p2: social:PersonShape violates sh:minCount on social:email
That line comes from the LPG Modeler walkthrough, where a model's required: true becomes exactly this constraint.
Takeaways
sh:minCountandsh:maxCountexpress required, optional, single- and multi-valued.- Absent,
nulland[]are all zero values; list elements count separately; duplicates count once. - Only counting constraints and
sh:hasValuenotice that a value is missing. Everything in the next chapter ignores empty sets.