This is the discord.js button-roles recipe done in discord.py, where interactive components are built as Views. Python adds one concept the JavaScript version doesn't need — persistent views — and getting it right is what keeps buttons alive after a restart.
| At a glance | |
|---|---|
| You need | a working bot from Host a discord.py bot, and the role IDs you want to offer |
| Plan | Free or premium |
| Time | About twenty-five minutes |
Start from the discord.py bot guide; cogs and structure come from Organize a discord.py bot with cogs.
The code
The complete app.py. Fill in the ROLES list with real role IDs (Developer Mode on → right-click a role in Server Settings → Copy Role ID):
import os
import discord
from discord import app_commands
from discord.ext import commands
from dotenv import load_dotenv
load_dotenv()
# Edit this list: one entry per self-assignable role (real role IDs).
ROLES = [
{"id": 111111111111111111, "label": "Announcements", "emoji": "📣"},
{"id": 222222222222222222, "label": "Events", "emoji": "🎉"},
{"id": 333333333333333333, "label": "Polls", "emoji": "📊"},
]
class RoleButton(discord.ui.Button):
def __init__(self, role_id: int, label: str, emoji: str):
super().__init__(
label=label,
emoji=emoji,
style=discord.ButtonStyle.secondary,
custom_id=f"role:{role_id}", # stable id → survives restarts
)
self.role_id = role_id
async def callback(self, interaction: discord.Interaction):
role = interaction.guild.get_role(self.role_id)
if role is None:
await interaction.response.send_message("That role no longer exists.", ephemeral=True)
return
if role in interaction.user.roles:
await interaction.user.remove_roles(role)
await interaction.response.send_message(f"Removed **{role.name}**.", ephemeral=True)
else:
await interaction.user.add_roles(role)
await interaction.response.send_message(f"Added **{role.name}**!", ephemeral=True)
class RoleView(discord.ui.View):
def __init__(self):
super().__init__(timeout=None) # persistent view
for r in ROLES:
self.add_item(RoleButton(r["id"], r["label"], r["emoji"]))
class MyBot(commands.Bot):
def __init__(self):
super().__init__(command_prefix="!", intents=discord.Intents.default())
async def setup_hook(self):
self.add_view(RoleView()) # re-register so old buttons keep working
await self.tree.sync()
bot = MyBot()
@bot.event
async def on_ready():
print(f"Listening as {bot.user}", flush=True)
@bot.tree.command(name="rolemenu", description="Post the self-role buttons here")
@app_commands.checks.has_permissions(manage_roles=True)
async def rolemenu(interaction: discord.Interaction):
await interaction.response.send_message("Click a button to toggle a role:", view=RoleView())
bot.run(os.environ["DISCORD_TOKEN"])
Restart, wait for /rolemenu, and run it in your roles channel.
The parts that matter
RoleButton(discord.ui.Button)is one button. Itscallbackruns when clicked: it looks up the role, and adds or removes it depending on whether the member already has it — the same toggle behaviour as the JavaScript version.RoleView(discord.ui.View)is the container. Its__init__loops overROLESand adds oneRoleButtoneach, so the whole menu is driven by that list.custom_id=f"role:{role_id}"andtimeout=Nonetogether make the view persistent. Without both, Discord forgets the buttons a few minutes after posting — or after any restart.self.add_view(RoleView())insetup_hookis the line most people miss. It re-registers the view every time the bot starts, so buttons on messages you posted yesterday still respond today. This is discord.py's one extra step compared with discord.js.@app_commands.checks.has_permissions(manage_roles=True)restricts/rolemenuto members who can manage roles, so only staff can post the menu.
🎯 Good to know: "Persistent view" is the whole trick. A view with a timeout (the default) works only while the bot has been running continuously since it posted the message. Setting
timeout=None, giving every button a fixedcustom_id, and callingadd_viewon startup are the three things that make buttons outlive a restart.
Permissions and intents
No privileged intents — discord.Intents.default() is enough.
| Requirement | Detail |
|---|---|
| Manage Roles permission | the bot's role needs it to add or remove roles |
| Role position | the bot's highest role must sit above every role it assigns |
⚠️ Heads up: Discord's role hierarchy is absolute — a bot can only grant roles below its own top role. If a click errors with
Forbidden, move the bot's role above the self-assignable ones in Server Settings → Roles.
Making it yours
- More than 25 roles? Use a
discord.ui.Selectinstead of buttons (a view holds at most 25 buttons across 5 rows; a select handles longer lists in one dropdown). - Colour the buttons. Swap
discord.ButtonStyle.secondaryforprimary,success, ordanger. - Exclusive picks. For "one colour only", remove the member's other colour roles before adding the new one inside the callback.
- Store the config. Move
ROLESto a JSON file or database so edits don't touch code — see Store data for your bot.
Troubleshooting
- Buttons stop responding after a restart — you're missing persistence. Confirm
timeout=None, a fixedcustom_idon every button, andself.add_view(RoleView())insetup_hook. Forbiddenon click — the bot lacks Manage Roles, or its role is below the target role. Fix both in Server Settings → Roles./rolemenunever appears —setup_hookmust callawait self.tree.sync(); then allow the first-time global delay.ValueErrorabout custom_id length — acustom_idmaxes at 100 characters; a role ID plusrole:is well within it, so this usually means you changed the prefix to something long.