Discord role positions aren't unique: the tie that quietly breaks your role checks A developer documented a subtle bug in Discord bot role logic: Discord API role positions are not unique, and ties are broken by role ID (lower ID ranks higher), so code that compares only the numeric position can silently make the wrong permission decision. The fix centralizes comparisons in a module that accepts any object with position and id, applying the ID tie-break with BigInt and returning null for non-comparable inputs. This article was written by an AI assistant Claude and reviewed by the bot's developer before publishing. The code comes from a production Discord bot and the behavior was checked against the Discord developer docs and the discord.js v14 source. If you write role logic for a Discord bot, you have probably written this line at some point: if role.position < botMember.roles.highest.position { await member.roles.add role ; } It reads like the Permission Hierarchy rule from the docs. With discord.js Role objects it is correct, but only because the library does extra work for you. Role positions in the Discord API are not unique, and the moment the number comes from somewhere else, the same check can quietly make the wrong decision. The Permission Hierarchy section of the Discord permissions docs https://docs.discord.com/developers/topics/permissions states the rule bots live by: A bot can grant roles to other users that are of a lower position than its own highest role. The catch is in the Role Structure table, on the position field: roles with the same position are sorted by id So position is a sort key, not a rank. Two roles can carry the same position value, and Discord breaks the tie with the role ID. If your code only compares the number, every tie collapses into "equal". Depending on whether you wrote < or = , an equal pair either always passes or always fails. The docs don't spell out which direction the ID tie-break goes. discord.js implements it as "the lower ID ranks higher" more on that below . Lower IDs are older snowflakes, so the role created first wins. We followed the same rule. It depends on what object you are holding. In discord.js v14, a Role has two fields: rawPosition is the value the API sent. position is a getter that counts how many cached roles sit below this one, breaking ties by ID. Here is the getter, shortened from discord.js/src/structures/Role.js v14.27 : js get position { return this.guild.roles.cache.reduce acc, role = acc + this.rawPosition === role.rawPosition ? BigInt this.id < BigInt role.id : this.rawPosition role.rawPosition , 0, ; } RoleManager comparePositions applies the same rule. So if you only ever compare discord.js Role objects through position or comparePositions , you are already safe. You are not safe when the number comes from somewhere else: fetch , That last one matters more than it sounds. If your tests build roles as { id, position } and never set two equal positions, the bug can't show up in CI. In our bot we found role comparisons spread across commands, the dashboard, the leveling, birthday and achievement code, and the server template code. We moved all of them to one small module that accepts any object with position and id , whether it is a discord.js Role , raw API JSON or a test fake. Shortened and with the names translated from the real file: js const hasPosition = r = Boolean r && r.position = null && Number.isFinite Number r.position ; const hasId = r = r.id = null && /^\d+$/.test String r.id ; // 0 if a ranks above b, < 0 if below, 0 if equal, null if not comparable. function compare a, b { if hasPosition a || hasPosition b return null; const pa = Number a.position ; const pb = Number b.position ; if pa == pb return pa - pb; // different positions: position decides if hasId a || hasId b return 0; // no ids: keep the old behavior const ia = BigInt String a.id ; const ib = BigInt String b.id ; if ia === ib return 0; return ib ia ? 1 : -1; // same position: lower id ranks higher } // Is role strictly below top ? Can the bot give it, edit it, sort it? function isBelow role, top { const c = compare role, top ; return c == null && c < 0; } A few decisions are worth calling out: BigInt . Number.MAX SAFE INTEGER . Comparing them as numbers or as strings of different lengths gives wrong answers. compare returns null and isBelow returns false . The safe answer to "can I give this role?" when you don't know is "no". Role objects too, where 0 keeps those tests meaningful instead of making them pass by accident. The call site then reads like the rule in the docs: if isBelow role, botMember.roles.highest { // can't give this role see the next section } The comparator makes the decision correct for any input. The bigger problem in our code was somewhere else: the old code skipped the role without telling anyone. Our original auto-role code looked like this: js const role = guild.roles.cache.get targetRoleId ; if role && role.position < guild.members.me.roles.highest.position { await member.roles.add role ; } That single if hides three different situations: the role was deleted, the role is above the bot, or the role is below the bot but roles.add failed. A server admin sees the same thing in all three cases: new members just don't get the role. The new version records a reason for each case: js const role = guild.roles.cache.get targetRoleId ; if role return reportIssue guild.id, 'autorole assign', { code: 'role-missing' } ; if isBelow role, guild.members.me.roles.highest { return reportIssue guild.id, 'autorole assign', { code: 'hierarchy', role: role.name } ; } await member.roles.add role .then = clearIssue guild.id, 'autorole assign' .catch e = reportIssue guild.id, 'autorole assign', { code: e.code } ; reportIssue writes to a small table that the bot's dashboard reads, so the admin sees "the bot's role must be above Member" instead of guessing. Whatever you use a log channel, a dashboard, a DM to the owner , the point is the same: when a bot decides not to do something it was configured to do, the decision should be visible. The cases worth pinning down in tests: | role | top bot | expected isBelow | |---|---|---| | position 1, id 200 | position 2, id 100 | true position decides | | position 1, id 200 | position 1, id 100 | true tie, higher id is below | | position 1, id 100 | position 1, id 200 | false tie, lower id is above | | position 1, id 100 | position 1, id 100 | false same role | | no position | position 1, id 100 | false unknown is not allowed | The second and third rows are the ones a naive < check gets wrong, and the ones most test suites never build. It also helps to scan the code for raw position comparisons so new ones don't creep back in. We added a test that fails when someone writes role.position < … outside the helper, with an explicit allow list for snapshot code. Role position for you. Raw JSON, I ran into this while working on VeyroBot https://veyrobot.xyz/en?utm source=devto&utm medium=article , a Discord server management bot that shows exactly this kind of failed action on its server health card.