How to Create Relationships with Different Cardinality Types in Neomodel

Use the cardinality parameter in RelationshipTo or RelationshipFrom and pass one of four manager classes—ZeroOrMore, ZeroOrOne, One, or OneOrMore—to enforce exactly how many target nodes a relationship may connect.

Neomodel, the Python Object Graph Mapper for Neo4j, provides precise control over relationship constraints through cardinality managers. When you create relationships with different cardinality types in neomodel, you define exactly how many nodes can participate in a relationship, preventing invalid graph states at runtime according to the source code in neomodel/sync_/cardinality.py.

Understanding Relationship Cardinality Managers

The cardinality system is implemented in neomodel/sync_/cardinality.py. Each manager subclasses RelationshipManager and overrides connection and disconnection logic to enforce constraints:

Defining Cardinality in Model Definitions

To apply cardinality constraints, pass the desired manager class to the cardinality parameter when declaring RelationshipTo, RelationshipFrom, or Relationship (for bidirectional links). The constructors for these relationship definitions reside in neomodel/sync_/relationship_manager.py (lines 24-30).

from neomodel import StructuredNode, RelationshipTo, ZeroOrOne, One, OneOrMore

class Employee(StructuredNode):
    # Optional single relationship

    manager = RelationshipTo("Manager", "REPORTS_TO", cardinality=ZeroOrOne)
    
    # Mandatory single relationship

    company = RelationshipTo("Company", "WORKS_FOR", cardinality=One)
    
    # Mandatory collection (at least one)

    projects = RelationshipTo("Project", "WORKS_ON", cardinality=OneOrMore)

Cardinality Types in Practice

ZeroOrMore (Default Behavior)

When you omit the cardinality parameter, neomodel uses ZeroOrMore. This imposes no limits on relationship count.

from neomodel import StructuredNode, RelationshipTo

class Monkey(StructuredNode):
    dryers = RelationshipTo("HairDryer", "OWNS_DRYER")  # ZeroOrMore by default

# Usage

m = Monkey(name="tim").save()
h1 = HairDryer(version=1).save()
h2 = HairDryer(version=2).save()

m.dryers.connect(h1)
m.dryers.connect(h2)  # Succeeds: no cardinality limit

assert len(m.dryers.all()) == 2

Source: test/sync_/test_cardinality.py (lines 83-94)

ZeroOrOne (Optional Singular Relationship)

The ZeroOrOne manager enforces at most one target node. Attempting to connect a second target raises AttemptedCardinalityViolation.

from neomodel import StructuredNode, RelationshipTo, ZeroOrOne
from neomodel.exceptions import AttemptedCardinalityViolation

class Monkey(StructuredNode):
    driver = RelationshipTo("ScrewDriver", "HAS_SCREWDRIVER", cardinality=ZeroOrOne)

m = Monkey(name="bob").save()
s1 = ScrewDriver(version=1).save()
s2 = ScrewDriver(version=2).save()

m.driver.connect(s1)  # First connection succeeds

# Attempting second connection raises exception

try:
    m.driver.connect(s2)
except AttemptedCardinalityViolation:
    print("Cannot connect more than one screwdriver")
    

# To replace, use reconnect

m.driver.reconnect(s1, s2)  # s2 replaces s1

assert m.driver.single().version == 2

Source: test/sync_/test_cardinality.py (lines 110-128)

One (Mandatory Singular Relationship)

The One manager requires exactly one target node at all times. It raises AttemptedCardinalityViolation when disconnecting the sole relationship and CardinalityViolation when accessing a missing relationship.

from neomodel import StructuredNode, RelationshipTo, One
from neomodel.exceptions import AttemptedCardinalityViolation, CardinalityViolation

class Monkey(StructuredNode):
    toothbrush = RelationshipTo("ToothBrush", "HAS_TOOTHBRUSH", cardinality=One)

m = Monkey(name="jerry").save()
tb = ToothBrush(name="bristle").save()

# Must create the relationship

m.toothbrush.connect(tb)

# Cannot disconnect the only relationship

try:
    m.toothbrush.disconnect(tb)
except AttemptedCardinalityViolation:
    print("Cannot remove the mandatory toothbrush")

# Accessing without relationship raises CardinalityViolation

fresh_monkey = Monkey(name="new").save()
try:
    fresh_monkey.toothbrush.all()
except CardinalityViolation:
    print("Mandatory relationship missing")

Source: test/sync_/test_cardinality.py (lines 152-166)

OneOrMore (Mandatory Collection)

The OneOrMore manager enforces at least one target node. You cannot disconnect the final relationship, and accessing an empty collection raises CardinalityViolation.

from neomodel import StructuredNode, RelationshipTo, OneOrMore
from neomodel.exceptions import AttemptedCardinalityViolation, CardinalityViolation

class Monkey(StructuredNode):
    car = RelationshipTo("Car", "HAS_CAR", cardinality=OneOrMore)

m = Monkey(name="jerry").save()
c1 = Car(version=2).save()
c2 = Car(version=3).save()

# Empty collection raises CardinalityViolation

try:
    m.car.all()
except CardinalityViolation:
    print("Must have at least one car")

# Add first car (now valid)

m.car.connect(c1)

# Can add more

m.car.connect(c2)
assert len(m.car.all()) == 2

# Can disconnect one

m.car.disconnect(c2)
assert len(m.car.all()) == 1

# Cannot disconnect the last one

try:
    m.car.disconnect(c1)
except AttemptedCardinalityViolation:
    print("Cannot remove the last car")

Source: test/sync_/test_cardinality.py (lines 151-188)

Handling Cardinality Violations

When cardinality constraints are violated, neomodel raises specific exceptions defined in neomodel/exceptions.py (lines 12-28):

  • AttemptedCardinalityViolation: Raised when an operation would create an invalid state, such as adding a second target to a ZeroOrOne relationship or removing the last target from a OneOrMore relationship.
  • CardinalityViolation: Raised when the current graph state violates the constraint, such as accessing a One relationship that has no target nodes.

Both exceptions inherit from NeomodelException, allowing you to catch all neomodel-specific errors with a single exception handler.

Summary

  • Use the cardinality parameter in RelationshipTo, RelationshipFrom, or Relationship to enforce relationship constraints in neomodel.
  • ZeroOrMore is the default manager allowing unlimited relationships, implemented in neomodel/sync_/relationship_manager.py (lines 616-622).
  • ZeroOrOne enforces at most one target node, raising AttemptedCardinalityViolation on duplicate connections (lines 14-30).
  • One requires exactly one target node, forbidding disconnection and raising CardinalityViolation when missing (lines 106-155).
  • OneOrMore requires at least one target node, preventing removal of the final relationship (lines 63-89).
  • All cardinality managers reside in neomodel/sync_/cardinality.py and raise specific exceptions defined in neomodel/exceptions.py.

Frequently Asked Questions

What is the default cardinality if I don't specify one?

If you omit the cardinality parameter, neomodel uses ZeroOrMore, which imposes no limits on the number of target nodes. This manager is defined in neomodel/sync_/relationship_manager.py (lines 616-622) and allows you to connect any number of nodes without raising cardinality violations.

Can I change the cardinality of an existing relationship?

No, cardinality is defined at the model level and enforced by Python manager classes. Changing cardinality requires updating your model definition and migrating your data to comply with the new constraints. For example, changing from ZeroOrMore to One requires ensuring every source node has exactly one target node before the code enforces the new rule.

What happens if I violate a cardinality constraint?

Neomodel raises specific exceptions based on the violation type. AttemptedCardinalityViolation occurs when you try to create an invalid state, such as connecting a second node to a ZeroOrOne relationship or disconnecting the last node from a OneOrMore relationship. CardinalityViolation occurs when accessing a relationship that violates its constraint, such as calling all() on a One relationship with no targets. Both exceptions are defined in neomodel/exceptions.py.

How do I replace a node in a ZeroOrOne or One relationship?

Use the reconnect(old_node, new_node) method provided by the cardinality managers. This atomically disconnects the existing node and connects the new one, maintaining the cardinality constraint throughout the operation. For example, employee.manager.reconnect(old_manager, new_manager) replaces the manager while ensuring the relationship never violates the ZeroOrOne or One constraint.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →