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.

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

Note: the following debug behavior was added in SpiceDB 1.52.0

If you see an error like:

rpc error: code = FailedPrecondition desc = max depth exceeded: this usually indicates a recursive or too deep data dependency. See https://spicedb.dev/d/debug-max-depth

on a LookupResources request, this indicates that either a branch was too deep or a cycle was encountered as SpiceDB was walking the set of reachable resources. You can debug this using zed.

Consider the following validation file:

schema: |- definition user {} definition folder { relation viewer: user relation parent: folder permission view = parent->view + viewer } definition resource { relation folder: folder permission view = folder->view } relationships: |- folder:a#viewer@user:someuser folder:a#parent@folder:b folder:b#parent@folder:a

Note that the parent relation on the folder definition is recursive, and that we’ve written a parent relationship from folder:a to folder:b and vice versa, creating a cycle.

If you run zed import on this file, and then run zed permission lookup-resources lookup-resources resource view user:someuser, you’ll get a max depth exceeded error.

You can re-run the request with --debug to get a stack trace that shows the cycle:

zed permission lookup-resources resource view user:someuser --debug

You’ll get output that looks like this:

The following resource/relation pairs were found in a cycle in LookupResources: - folder:a#view To further debug this, issue a check from the resource to itself across the relation. For example, with the identified pair `resource:foo#view`, you would make the following call: zed permission check resource:foo view resource:foo --explain For more information, see the LookupResources section under https://spicedb.dev/d/debug-max-depth

If you call zed permission check folder:a view folder:a --explain, you’ll get output like the following:

! folder:a view (8.434693ms) └── ! folder:b view (8.143951ms) └── ! folder:a view (cycle) (7.970042ms)

Some notes on reading the output:

  • The lines should be read as resources with the relation connecting them to the previous pair. For example, in the line marked by (cycle) can be read eas “there’s a view relation between folder:a and folder:b.”
  • The relation can a schema relation, a schema permission, or a schema subject relation. Notice that in the line marked by cycle, the relation is view, corresponding to the view permission on the folder definition in the schema. This means that you might need to do some translation to connect the relation in a line to a concrete relationship.
  • The (cycle) marks the endpoints where a resource that is a member of a cycle was seen for the second time. It does not mean that the marked relation is the offending relation, just that everything between the marks is a member of the cycle. You’ll need to think about which relationships are written where to understand what needs to change in your business logic to avoid the cycle.

In this case, we can see in the output that folder#view is going back and forth between folder:a and folder:b, and from the permission definition we can see that folder#view is defined in terms of viewer and parent. viewer can’t be a part of a cycle, because it points at a user and there’s no outgoing relation from the user definition. This means that parent is the offending relation, and we can remove one of the folder#parent relationships to break the cycle.

Limitations

Schema Structure

If your schema has certain structures, calling zed permission check --explain may return “no permission” rather than showing you a cycle. For example, if we changed the folder definition in the schema above to the following:

definition folder { relation viewer: user relation editor: user relation folder: parent permission view = (viewer + parent->view) & editor }

to indicate that the user must also be an editor in order to view, folder:a#view@folder:a can never be satisfied, because it can never satisfy the & editor clause of the permission. Intersection arrows and certain negations may result in similar behavior.

One potential workaround is to make a check with the desired terminal subject (usually a user) on that object:

zed permission check folder:a view user:someuser --explain

This will work if the permission walk enters the cycle and then cannot exit, but will potentially terminate normally if there’s a permission path that terminates after branching off of the cycle.

Caveats

If your schema has caveats and there are caveats along the LookupResources path, you’ll also need to recover the original caveat context and submit it in the request.

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.