Self-assign roles with buttons (discord.py)

The discord.py version of button roles, built on persistent Views — the pattern that keeps old buttons working after your bot restarts.

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. Its callback runs 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 over ROLES and adds one RoleButton each, so the whole menu is driven by that list.
  • custom_id=f"role:{role_id}" and timeout=None together make the view persistent. Without both, Discord forgets the buttons a few minutes after posting — or after any restart.
  • self.add_view(RoleView()) in setup_hook is 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 /rolemenu to 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 fixed custom_id, and calling add_view on 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.Select instead 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.secondary for primary, success, or danger.
  • 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 ROLES to 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 fixed custom_id on every button, and self.add_view(RoleView()) in setup_hook.
  • Forbidden on click — the bot lacks Manage Roles, or its role is below the target role. Fix both in Server Settings → Roles.
  • /rolemenu never appearssetup_hook must call await self.tree.sync(); then allow the first-time global delay.
  • ValueError about custom_id length — a custom_id maxes at 100 characters; a role ID plus role: is well within it, so this usually means you changed the prefix to something long.

Next steps

Was this guide helpful?