Skip to Content
SpiceDB is 100% open source. Please help us by starring our GitHub repo. ↗
SpiceDB DocumentationModelingCyclical Relationships

Cyclical Relationships and Traversal Limits

SpiceDB answers permissions questions by traversing a tree constructed from your schema (structure) and relationships (data).

When you call CheckPermission, SpiceDB starts at the resource and permission you specified, then walks through relations and permissions until it either finds the subject or determines the subject doesn’t have access.

How Traversal Works

Consider this simple example:

┌─────────────────────┐ │ document:readme │ │ permission: view │ └─────────┬───────────┘ │ viewer relation ┌─────────────────────┐ │ group:engineering │ │ permission: member │ └─────────┬───────────┘ │ member relation ┌─────────────────────┐ │ user:alice │ └─────────────────────┘

When checking if user:alice can view document:readme, SpiceDB traverses:

  1. document:readme#view → follows viewer relation
  2. group:engineering#member → follows member relation
  3. Found user:alice → returns allowed

Each arrow represents one “hop” in the traversal. This tree has a depth of 3 (three nodes visited).

Traversal Depth Limit

To prevent unbounded traversal, SpiceDB enforces a maximum depth limit on every path traversed during a CheckPermission request. By default, this limit is 50 hops. If a traversal exceeds this limit, SpiceDB returns an error rather than continuing indefinitely.

You can configure this limit with the --dispatch-max-depth flag:

spicedb serve --dispatch-max-depth=100

Most schemas work well within the default limit. You typically only need to increase it if you have legitimately deep hierarchies (like deeply nested folder structures).

Cyclical Relationships (Cycles)

A cycle occurs when traversing the permissions tree leads back to an object that was already visited. SpiceDB does not support cyclical relationships because the permissions graph must be a tree , not a graph with loops.

Example of a Cycle

Consider this schema for nested groups:

definition user {} definition group { relation member: user | group#member } definition resource { relation viewer: user | group#member permission view = viewer }

With these relationships:

resource:someresource#viewer@group:firstgroup#member group:firstgroup#member@group:secondgroup#member group:secondgroup#member@group:thirdgroup#member group:thirdgroup#member@group:firstgroup#member ← creates a cycle!

Visually, this creates a loop:

┌──────────────────────┐ │ resource:someresource│ │ permission: view │ └──────────┬───────────┘ │ viewer ┌──────────────────────┐ │ group:firstgroup │◄─────────────────┐ │ permission: member │ │ └──────────┬───────────┘ │ │ member │ ▼ │ ┌──────────────────────┐ │ │ group:secondgroup │ │ member │ permission: member │ │ (cycle!) └──────────┬───────────┘ │ │ member │ ▼ │ ┌──────────────────────┐ │ │ group:thirdgroup │──────────────────┘ │ permission: member │ └──────────────────────┘

When SpiceDB traverses this, it walks: resource:someresource#viewergroup:firstgroup#membergroup:secondgroup#membergroup:thirdgroup#membergroup:firstgroup#member → …

The traversal returns to group:firstgroup#member, creating an infinite loop.

How SpiceDB Handles Cycles

SpiceDB does not have a dedicated cycle detector. Instead, when a cycle exists, the traversal continues looping until it hits the maximum depth limit, then returns an error. This same error occurs whether the cause is a cycle or simply a very deep (but acyclic) hierarchy.

Why not track visited objects? SpiceDB intentionally avoids tracking visited objects for two reasons:

  1. Semantic problems with self-referential sets: When a group’s members include itself, it creates logical paradoxes.

    Consider this example:

    definition user {} definition group { relation direct_member: user | group#member relation banned: user | group#member permission member = direct_member - banned }
    group:firstgroup#direct_member@group:secondgroup#member group:firstgroup#banned@group:bannedgroup#member group:secondgroup#direct_member@user:tom group:bannedgroup#direct_member@group:firstgroup#member

    user:tom is a direct_member of secondgroup, which makes him a member of firstgroup → which implies he’s a member of bannedgroup → which implies he’s not a member of firstgroup → thus making him no longer banned → (logical inconsistency).

  2. Performance overhead: Tracking every visited object would require significant memory and network overhead, especially in distributed deployments.

Common Questions

What do I do about a max depth error on CheckPermission?

If you see an error like:

the check request has exceeded the allowable maximum depth of 50: this usually indicates a recursive or too deep data dependency. Try running zed with --explain to see the dependency

Use zed permission check with --explain to visualize the traversal path:

zed permission check resource:someresource view user:someuser --explain
1:36PM INF debugging requested on check ! resource:someresource viewer (4.084125ms) └── ! group:firstgroup member (3.445417ms) └── ! group:secondgroup member (3.338708ms) └── ! group:thirdgroup member (3.260125ms) └── ! group:firstgroup member (cycle) (3.194125ms)

The output shows each hop in the traversal. If you see (cycle) in the output, you have a cyclical relationship. If there’s no cycle, your hierarchy is simply deeper than the limit allows.

Why did my check succeed despite having a cycle?

SpiceDB short-circuits CheckPermission when it finds the subject. If the subject is found before the traversal hits the cycle or exceeds the depth limit, the check succeeds.

However, if the subject is not found, the traversal continues until it hits the depth limit and returns an error.

How do I prevent cycles when writing relationships?

Before writing a relationship that could create a cycle, use CheckPermission to verify the relationship won’t create a loop.

For example, before writing group:parent#member@group:child#member, check if the parent is already reachable from the child:

zed permission check group:child member group:parent

If this check returns allowed, writing the relationship would create a cycle. If it returns denied, the relationship is safe to write.

This pattern works because: if the parent already has permission on the child, making the child a member of the parent creates a circular dependency.