Skip to content
CreateDiscordBot
Menu

How to make a Discord bot in Python with discord.py (slash commands)

Build a Discord bot in Python with discord.py 2.7: slash commands with options and choices, cogs, buttons, one error handler and guild vs global sync.

By Adam Peleback. Updated . 16 min read.

On this page
  1. What you'll build
  2. Step 1: Create the application and copy the token
  3. Step 2: Invite the bot to a test server
  4. Step 3: Create the virtual environment and install discord.py
  5. Step 4: Write bot.py
  6. Sync slash commands: guild for testing, global for launch
  7. Step 5: Your first Python slash command, with choices
  8. Step 6: The LFG cog, with options, a cooldown and buttons
  9. Handling errors in one place
  10. Step 7: Run the bot
  11. How I tested this code
  12. Troubleshooting
  13. Running the bot 24/7
  14. What a production bot adds

To make a Discord bot in Python with slash commands, you create an application in the Discord Developer Portal, install discord.py 2.x in a virtual environment, write each command as an app_commands function, and sync the commands to Discord. During development you sync them to one test server, where they update instantly. When they're ready, you sync them globally.

This guide builds a complete bot in three Python files: a /lfg command that posts a looking-for-group message with Join, Leave and Close buttons, and a /timestamp command that turns a date into a Discord timestamp. Along the way it covers intents, a cog-based layout, options and choices, a cooldown, one error handler for every command, and running the bot 24/7.

I tested every file on this page on September 27, 2026 with discord.py 2.7.1 on Python 3.14.3 and 3.12.13 (macOS). What I tested and how is further down.

What you'll build

  • /lfg game players note posts a public message: the game, a player list and three buttons. Members press Join or Leave, the list updates in place, and Join turns off when the group is full. The host can close the group, and the buttons switch off by themselves after three hours without a click.
  • /timestamp date time timezone style answers privately with a timestamp such as <t:1791070200:F>, which every reader sees in their own time zone.
  • One error handler that answers the member with a short message and logs the real error for you, plus a 60-second cooldown on /lfg.
  • A --sync flag that registers the commands in your test server, globally, or removes the test copies.

The project looks like this:

pybot/
├── .env
├── .gitignore
├── requirements.txt
├── bot.py
└── cogs/
    ├── lfg.py
    └── utility.py

You need Python 3.10 or newer (discord.py itself supports 3.8 and up, but python-dotenv and the str | None syntax used here need 3.10), a Discord server where you have Manage Server, and a code editor. If you'd rather not write code at all, a no-code builder may cover you; Discord bot makers compared looks at the options.

Step 1: Create the application and copy the token

  1. Open the Discord Developer Portal and create a new application. Its name is the bot's name.
  2. On the Bot page, click Reset Token and copy the token. Anyone who has it controls your bot, so it only ever goes in .env, never in code or in a chat. The Discord bot token guide covers what to do if it leaks.
  3. Leave the three Privileged Gateway Intents switched off.

That last step surprises people who followed older tutorials. Those bots read every message to find !commands, which needs the Message Content intent. Slash commands and buttons arrive as interactions instead: Discord parses the options, and the payload includes the member's roles and permissions "with no intent required", as Discord's own guide puts it. So this bot runs on discord.Intents.default(), which in discord.py is everything except the three privileged intents (members, presences and message content).

If you ever do turn one on in code, switch it on in the portal as well. Otherwise Discord closes the connection with close code 4014 and discord.py raises PrivilegedIntentsRequired. As of September 2026, anyone can switch privileged intents on until the app reaches 10,000 unique users across its servers. After that, Discord has to approve them in a review that is repeated every year.

Step 2: Invite the bot to a test server

Use a server you own for testing, so you can sync commands there without affecting anyone. Build an invite link with the permissions calculator, or use this one with your application ID from the General Information page:

invite URL
https://discord.com/oauth2/authorize?client_id=YOUR_APPLICATION_ID&scope=bot&permissions=19456

19456 is View Channels, Send Messages and Embed Links. You don't need to add the applications.commands scope separately, because Discord includes it with the bot scope. The bot needs View Channels in any channel where people use /lfg, for a reason that comes up in step 6.

Then turn on Developer Mode (User Settings, Advanced) and right-click your server icon, Copy Server ID. That number is your DEV_GUILD_ID.

Step 3: Create the virtual environment and install discord.py

A virtual environment keeps the bot's packages separate from everything else on your machine. Create the folder, the environment and the packages:

macOS and Linux
mkdir pybot && cd pybot
python3 -m venv .venv
source .venv/bin/activate
Windows (PowerShell)
mkdir pybot; cd pybot
py -3 -m venv .venv
.venv\Scripts\Activate.ps1

If PowerShell refuses to run Activate.ps1, the Python docs give the fix: Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope CurrentUser. In cmd.exe the activation command is .venv\Scripts\activate.bat.

Create requirements.txt and install from it:

requirements.txt
discord.py>=2.7.1
python-dotenv>=1.2
tzdata
pip install -r requirements.txt

python-dotenv loads .env. tzdata is there for Windows: Python's zoneinfo module uses the operating system's time zone database, Windows doesn't ship one, and without it /timestamp fails with ZoneInfoNotFoundError. On macOS and Linux it is harmless.

Now create .env with your token and test server ID, and a .gitignore so neither the token nor the virtual environment ends up in git:

.env
DISCORD_TOKEN=paste-your-bot-token-here
DEV_GUILD_ID=123456789012345678
.gitignore
.env
.venv/
__pycache__/

By default, load_dotenv() doesn't overwrite variables that are already set. On a host where you set DISCORD_TOKEN in a dashboard, the real environment wins and you don't upload .env at all.

Step 4: Write bot.py

bot.py creates the bot, loads the two cogs, holds the error handler and decides whether to sync. The sections after the code explain each decision.

bot.py
import argparse
import logging
import math
import os

import discord
from discord import app_commands
from discord.ext import commands
from dotenv import load_dotenv

log = logging.getLogger("bot")

# Each module in cogs/ is an extension with a setup() function.
EXTENSIONS = ("cogs.lfg", "cogs.utility")


class BotTree(app_commands.CommandTree):
    """The command tree, with one error handler for every slash command."""

    async def on_error(self, interaction: discord.Interaction, error: app_commands.AppCommandError) -> None:
        if isinstance(error, app_commands.CommandOnCooldown):
            message = f"Slow down. You can use this again in {math.ceil(error.retry_after)} seconds."
        elif isinstance(error, app_commands.MissingPermissions):
            message = "You don't have permission to use this command."
        elif isinstance(error, app_commands.CheckFailure):
            message = "You can't use this command here."
        else:
            name = interaction.command.qualified_name if interaction.command else "unknown"
            log.error("Command /%s failed", name, exc_info=error)
            message = "Something went wrong. The error has been logged."

        try:
            if interaction.response.is_done():
                await interaction.followup.send(message, ephemeral=True)
            else:
                await interaction.response.send_message(message, ephemeral=True)
        except discord.HTTPException:
            log.warning("Could not send the error message for interaction %s", interaction.id)


class Bot(commands.Bot):
    def __init__(self, sync: str | None, dev_guild_id: int | None) -> None:
        super().__init__(
            # Slash commands don't use a prefix. Mention-only stops discord.py from
            # warning about the privileged Message Content intent.
            command_prefix=commands.when_mentioned,
            help_command=None,
            intents=discord.Intents.default(),
            tree_cls=BotTree,
        )
        self.sync = sync
        self.dev_guild_id = dev_guild_id

    async def setup_hook(self) -> None:
        # Runs once, after login and before the bot connects to the gateway.
        for extension in EXTENSIONS:
            await self.load_extension(extension)

        if self.sync == "global":
            synced = await self.tree.sync()
            log.info("Synced %d global command(s)", len(synced))
        elif self.sync == "guild":
            guild = discord.Object(id=self.dev_guild_id)
            self.tree.copy_global_to(guild=guild)
            synced = await self.tree.sync(guild=guild)
            log.info("Synced %d command(s) to server %s", len(synced), guild.id)
        elif self.sync == "clear-guild":
            guild = discord.Object(id=self.dev_guild_id)
            self.tree.clear_commands(guild=guild)
            await self.tree.sync(guild=guild)
            log.info("Removed the test commands from server %s", guild.id)

    async def on_ready(self) -> None:
        log.info("Logged in as %s (ID %s)", self.user, self.user.id)


def main() -> None:
    parser = argparse.ArgumentParser(description="Run the Discord bot.")
    parser.add_argument(
        "--sync",
        choices=["guild", "global", "clear-guild"],
        help="guild: copy the commands to DEV_GUILD_ID (instant). "
        "global: publish them to every server. "
        "clear-guild: remove the test copies from DEV_GUILD_ID.",
    )
    args = parser.parse_args()

    load_dotenv()
    token = os.getenv("DISCORD_TOKEN")
    if not token:
        raise SystemExit("DISCORD_TOKEN is missing. Add it to .env.")
    dev_guild_id = os.getenv("DEV_GUILD_ID")
    if args.sync in ("guild", "clear-guild") and not dev_guild_id:
        raise SystemExit("DEV_GUILD_ID is missing. Add your test server's ID to .env.")

    bot = Bot(sync=args.sync, dev_guild_id=int(dev_guild_id) if dev_guild_id else None)
    bot.run(token, root_logger=True)


if __name__ == "__main__":
    main()

Why commands.Bot and not discord.Client

A plain discord.Client plus an app_commands.CommandTree runs slash commands fine. commands.Bot adds extensions, which is what lets each group of commands live in its own file under cogs/ and be loaded with load_extension. It also creates the command tree for you; tree_cls=BotTree swaps in the subclass that holds the error handler.

commands.Bot still expects a prefix for old-style text commands. commands.when_mentioned means "only when someone @mentions the bot". With any other prefix and no Message Content intent, discord.py logs "Privileged message content intent is missing, commands may not work as expected" at every start. help_command=None removes the text help command this bot doesn't need.

Why setup_hook

setup_hook runs once, after login and before the bot connects to Discord's gateway. It is the right place to load extensions and sync, because on_ready can fire again every time the bot reconnects. bot.run(token, root_logger=True) attaches discord.py's log handler to Python's root logger, so your own bot logger's lines are printed too. Without it, the handler sits on discord.py's logger only, and your log.info lines never show.

Sync slash commands: guild for testing, global for launch

Discord only shows a slash command after you send it the command's definition: name, description, options, choices and default permissions. discord.py builds that from your decorators and type hints, and tree.sync() sends it. The function body never leaves your machine, so you only need to sync again when one of those definition parts changes. For how commands look and behave from the member's side, see how Discord slash commands work.

Guild sync Global sync
Run python bot.py --sync guild python bot.py --sync global
Where the commands appear Only the server in DEV_GUILD_ID Every server the bot is in
How fast changes show Instantly, per Discord's docs Slower; discord.py's own example says up to an hour
Use it for Development Launch

copy_global_to(guild=...) is what makes the guild sync work without extra decorators: it copies every global command into your test server's list before the sync. The official discord.py example does the same.

The example syncs inside setup_hook on every start. I put it behind a flag instead, for two reasons. Discord limits command creation to 200 per day per server, and AbstractUmbra, a discord.py contributor, writes that the rate limits on command registration "can be harsh", with 24-hour lockouts. And a sync you run on purpose is a sync you notice when it fails.

One trap catches almost everyone. Discord lets an app have a global command and a guild command with the same name, so after you go global, your test server shows every command twice. Remove the test copies once:

python bot.py --sync clear-guild

That sends an empty command list for your test server only. The global commands stay.

Step 5: Your first Python slash command, with choices

Create cogs/utility.py. It holds /timestamp, the simpler of the two commands, and shows how options and choices work.

cogs/utility.py
from datetime import datetime
from zoneinfo import ZoneInfo

import discord
from discord import app_commands
from discord.ext import commands

TIMEZONES = [
    app_commands.Choice(name="US Eastern (New York)", value="America/New_York"),
    app_commands.Choice(name="US Central (Chicago)", value="America/Chicago"),
    app_commands.Choice(name="US Mountain (Denver)", value="America/Denver"),
    app_commands.Choice(name="US Pacific (Los Angeles)", value="America/Los_Angeles"),
    app_commands.Choice(name="UK (London)", value="Europe/London"),
    app_commands.Choice(name="Central Europe (Berlin)", value="Europe/Berlin"),
    app_commands.Choice(name="Australia Eastern (Sydney)", value="Australia/Sydney"),
    app_commands.Choice(name="UTC", value="UTC"),
]

STYLES = [
    app_commands.Choice(name="Full: Tuesday, April 20, 2021 at 16:20", value="F"),
    app_commands.Choice(name="Date and time: April 20, 2021 at 16:20", value="f"),
    app_commands.Choice(name="Date: April 20, 2021", value="D"),
    app_commands.Choice(name="Time: 16:20", value="t"),
    app_commands.Choice(name="Relative: in 2 hours", value="R"),
]


class Utility(commands.Cog):
    def __init__(self, bot: commands.Bot) -> None:
        self.bot = bot

    @app_commands.command(description="Turn a date and time into a timestamp that shows in everyone's time zone")
    @app_commands.describe(
        date="Date as YYYY-MM-DD, for example 2026-10-03",
        time="24-hour time as HH:MM, for example 19:30",
        timezone="The time zone that date and time are in",
        style="How Discord should display it (default: Full)",
    )
    @app_commands.choices(timezone=TIMEZONES, style=STYLES)
    async def timestamp(
        self,
        interaction: discord.Interaction,
        date: str,
        time: str,
        timezone: app_commands.Choice[str],
        style: app_commands.Choice[str] | None = None,
    ) -> None:
        try:
            local = datetime.strptime(f"{date} {time}", "%Y-%m-%d %H:%M")
        except ValueError:
            await interaction.response.send_message(
                "Use YYYY-MM-DD for the date and HH:MM for the time, for example 2026-10-03 and 19:30.",
                ephemeral=True,
            )
            return

        moment = local.replace(tzinfo=ZoneInfo(timezone.value))
        code = discord.utils.format_dt(moment, style.value if style else "F")
        relative = discord.utils.format_dt(moment, "R")
        await interaction.response.send_message(
            f"{code} ({relative})\nPaste this into any message: `{code}`",
            ephemeral=True,
        )


async def setup(bot: commands.Bot) -> None:
    await bot.add_cog(Utility(bot))

How the pieces map to what members see:

  • Options come from the parameters. Every parameter after interaction becomes an option. The type hint sets the option type (str is a text option), and a default value makes the option optional. The function name becomes the command name, so keep it lowercase.
  • @app_commands.describe sets the grey help text under each option. Discord allows 1 to 100 characters. Leave it out and discord.py sends a bare "…", which tells the member nothing. The command's own description comes from description= or the function's docstring.
  • @app_commands.choices turns an option into a fixed menu. Each Choice has a name the member sees and a value your code receives, which is how "US Eastern (New York)" arrives as America/New_York. Annotate the parameter as app_commands.Choice[str] and read .value. Discord allows at most 25 choices per option; for longer lists, use autocomplete instead.
  • ephemeral=True makes the reply visible only to the member who ran the command, which suits a helper like this.
  • discord.utils.format_dt builds Discord's <t:unix:style> markup. Discord renders it in each reader's own time zone and language. The time zone conversion also handles daylight saving time: 19:30 in New York is 23:30 UTC in October and 00:30 UTC the next day in January.

The site has a timestamp generator that does the same thing in the browser, if you want to compare output.

Every extension needs an async def setup(bot) function. discord.py calls it when load_extension("cogs.utility") runs, and commands defined with @app_commands.command inside a cog are added to the tree when the cog is added.

Step 6: The LFG cog, with options, a cooldown and buttons

Create cogs/lfg.py. This is the bigger file: a view class for the buttons and a cog with the /lfg command.

cogs/lfg.py
import logging
from typing import Literal

import discord
from discord import app_commands
from discord.ext import commands

log = logging.getLogger(__name__)

# The buttons stop working after this many seconds without a click.
LFG_TIMEOUT = 3 * 60 * 60

Game = Literal["Counter-Strike 2", "VALORANT", "League of Legends", "Rocket League", "Fortnite"]


class LfgView(discord.ui.View):
    def __init__(self, host_id: int, game: str, slots: int, note: str | None) -> None:
        super().__init__(timeout=LFG_TIMEOUT)
        self.host_id = host_id
        self.game = game
        self.slots = slots
        self.note = note
        self.player_ids = [host_id]
        self.closed = False
        # Set by the command after the post is sent. A PartialMessage edits
        # through the channel, so it keeps working after the interaction expires.
        self.message: discord.PartialMessage | None = None

    def build_embed(self) -> discord.Embed:
        if self.closed:
            status = "Closed"
        elif len(self.player_ids) >= self.slots:
            status = "Full"
        else:
            status = f"{self.slots - len(self.player_ids)} spot(s) left"

        embed = discord.Embed(title=f"LFG: {self.game}", description=self.note, color=discord.Color.blurple())
        embed.add_field(
            name=f"Players ({len(self.player_ids)}/{self.slots})",
            value="\n".join(f"<@{user_id}>" for user_id in self.player_ids),
        )
        embed.set_footer(text=status)
        return embed

    async def refresh(self, interaction: discord.Interaction) -> None:
        self.join_button.disabled = self.closed or len(self.player_ids) >= self.slots
        self.leave_button.disabled = self.closed
        self.close_button.disabled = self.closed
        await interaction.response.edit_message(embed=self.build_embed(), view=self)

    @discord.ui.button(label="Join", style=discord.ButtonStyle.success)
    async def join_button(self, interaction: discord.Interaction, button: discord.ui.Button) -> None:
        if interaction.user.id in self.player_ids:
            await interaction.response.send_message("You're already in this group.", ephemeral=True)
            return
        if len(self.player_ids) >= self.slots:
            await interaction.response.send_message("This group is full.", ephemeral=True)
            return
        self.player_ids.append(interaction.user.id)
        await self.refresh(interaction)

    @discord.ui.button(label="Leave", style=discord.ButtonStyle.secondary)
    async def leave_button(self, interaction: discord.Interaction, button: discord.ui.Button) -> None:
        if interaction.user.id == self.host_id:
            await interaction.response.send_message("You're the host. Press Close to end the group.", ephemeral=True)
            return
        if interaction.user.id not in self.player_ids:
            await interaction.response.send_message("You're not in this group.", ephemeral=True)
            return
        self.player_ids.remove(interaction.user.id)
        await self.refresh(interaction)

    @discord.ui.button(label="Close", style=discord.ButtonStyle.danger)
    async def close_button(self, interaction: discord.Interaction, button: discord.ui.Button) -> None:
        if interaction.user.id != self.host_id:
            await interaction.response.send_message("Only the host can close this group.", ephemeral=True)
            return
        self.closed = True
        self.stop()
        await self.refresh(interaction)

    async def on_timeout(self) -> None:
        self.closed = True
        for item in self.children:
            item.disabled = True
        if self.message is None:
            return
        try:
            await self.message.edit(embed=self.build_embed(), view=self)
        except discord.HTTPException:
            log.warning("Could not close LFG post %s after the timeout", self.message.id)

    async def on_error(self, interaction: discord.Interaction, error: Exception, item: discord.ui.Item) -> None:
        # Errors in button callbacks land here, not in the command tree's handler.
        log.error("LFG button %r failed", item, exc_info=error)
        message = "Something went wrong with that button."
        try:
            if interaction.response.is_done():
                await interaction.followup.send(message, ephemeral=True)
            else:
                await interaction.response.send_message(message, ephemeral=True)
        except discord.HTTPException:
            pass


class Lfg(commands.Cog):
    def __init__(self, bot: commands.Bot) -> None:
        self.bot = bot

    @app_commands.command(description="Post a looking-for-group message with Join and Leave buttons")
    @app_commands.describe(
        game="The game you want to play",
        players="Group size including you, 2 to 10",
        note="Rank, start time or anything else people should know",
    )
    @app_commands.guild_only()
    @app_commands.checks.cooldown(1, 60)
    async def lfg(
        self,
        interaction: discord.Interaction,
        game: Game,
        players: app_commands.Range[int, 2, 10] = 5,
        note: app_commands.Range[str, 1, 200] | None = None,
    ) -> None:
        view = LfgView(host_id=interaction.user.id, game=game, slots=players, note=note)
        await interaction.response.send_message(embed=view.build_embed(), view=view)
        sent = await interaction.original_response()
        view.message = interaction.channel.get_partial_message(sent.id)


async def setup(bot: commands.Bot) -> None:
    await bot.add_cog(Lfg(bot))

The options

  • Literal[...] is the other way to make choices. When the name members see and the value you want are the same, a Literal of strings is shorter than a list of Choice objects. discord.py turns it into five choices.
  • app_commands.Range[int, 2, 10] sends a minimum and maximum to Discord, so the client refuses 1 or 11 before your code runs. Range[str, 1, 200] does the same for text length.
  • @app_commands.guild_only() tells Discord the command only exists in servers, so it doesn't show up in DMs with the bot. Discord enforces this; discord.py doesn't add a runtime check.
  • @app_commands.checks.cooldown(1, 60) allows one use per member per 60 seconds. A second try raises CommandOnCooldown, which the error handler in bot.py turns into "Slow down. You can use this again in 42 seconds."

The buttons

A discord.ui.View holds the buttons, and each @discord.ui.button method is the callback for one button. discord.py routes each click to the right view instance, so every LFG post keeps its own player list.

Every button click has to be answered within 3 seconds. interaction.response.edit_message(...) answers by updating the message that holds the button, which is why the list changes in place. Replies such as "This group is full" use send_message(..., ephemeral=True) instead, so only the person who clicked sees them.

The timeout counts from the last click, not from when the post was made, and on_timeout runs when it runs out. Here it disables all three buttons and marks the post "Closed", so nobody clicks a dead button.

The 15-minute trap

The obvious way to keep a handle on the post is self.message = await interaction.original_response(). That returns an InteractionMessage, and editing one goes through the interaction's token. Discord's docs say interaction tokens are valid for 15 minutes. After that, the edit in on_timeout fails with 401 Unauthorized (error code: 50027): Invalid Webhook Token. The discord.py maintainers closed a bug report about exactly this as a Discord limitation.

So the command stores a PartialMessage from the channel instead. Its edit() goes through the channel's message endpoint rather than the interaction token, so it keeps working hours later, as long as the bot can see the channel. That is why the invite in step 2 includes View Channels. If the bot can't see the channel, the edit fails, on_timeout logs a warning and nothing crashes.

Handling errors in one place

discord.py sends errors to different handlers depending on where they happen:

Where the error happens Handler that receives it In this bot
Any slash command, including failed checks and cooldowns CommandTree.on_error BotTree.on_error in bot.py
Commands in one cog only Cog.cog_app_command_error, and then CommandTree.on_error as well Not used
A button, select menu or other view item View.on_error LfgView.on_error

The tree handler answers every error privately. Expected errors get a specific message, and anything unexpected is logged with its full traceback while the member sees "Something went wrong". It checks interaction.response.is_done() first: an interaction can only be answered once, so if the command already responded before failing, the message goes out as a followup instead. If even that fails, for example because the interaction expired, it logs a warning instead of raising a second error.

Two details catch many people. A cog handler doesn't replace the tree handler: discord.py calls both, so only one of them should answer the member. And a crash inside a button callback never reaches CommandTree.on_error at all. Without View.on_error, discord.py only logs it, and the member sees "This interaction failed".

Step 7: Run the bot

With the virtual environment active, run the bot once with a guild sync:

python bot.py --sync guild

In a macOS or Linux terminal the first lines look like this (in color), with your own time and server ID:

terminal
2026-09-27 19:04:35 INFO     discord.client logging in using static token
2026-09-27 19:04:35 INFO     bot Synced 2 command(s) to server 123456789012345678

Then discord.gateway reports that it has connected, and the bot logs Logged in as followed by its name and ID. Where the output isn't a terminal, such as a log file or systemd's journal, the same lines appear without color in the form [2026-09-27 19:04:35] [INFO ] bot: Synced ....

From then on, start it with plain python bot.py and only add --sync guild after you change a command's name, options, choices or descriptions. Then, in your test server:

  1. Type /timestamp, enter a date such as 2026-10-03 and a time such as 19:30, pick a time zone and leave style empty. You get a private reply with the full date and a relative time, plus the code to paste.
  2. Run /lfg game:VALORANT players:3. The post shows you as player 1 of 3.
  3. Press Join from a second account. The list updates and the footer counts down. When the group is full, Join turns grey.
  4. Run /lfg again within a minute. You get the cooldown message.
  5. Press Close as the host. All buttons switch off and the footer says "Closed".

Press Ctrl+C to stop the bot. When you're ready for everyone, run python bot.py --sync global once, and python bot.py --sync clear-guild once to remove the duplicates from your test server.

How I tested this code

I can't log in to Discord from a test script without a real bot token, so I tested the published files unchanged against a fake Discord. The harness replaces every HTTP request discord.py makes, both the bot's REST calls and the interaction webhooks, with a recorder. Then it feeds interaction payloads shaped like Discord's into discord.py's own dispatch, so the real command tree, transformers, checks, cooldowns, views and error handlers run.

With discord.py 2.7.1, python-dotenv 1.2.3 and tzdata 2026.4, all 62 checks passed on Python 3.14.3 and on Python 3.12.13, both on macOS. With warnings switched on, Python 3.12 also prints a DeprecationWarning about the audioop module from discord.py's voice code, which this bot doesn't use. The main cases:

Case What was checked
No --sync Both cogs load; no command registration request is sent
--sync guild One PUT to the guild commands route with both commands
--sync global One PUT to the global route, with the same payload
--sync clear-guild An empty list sent to the guild route; global commands untouched
Command payloads Lowercase names, descriptions of 1 to 100 characters, 5 game choices, players 2 to 10, note length 1 to 200, /lfg limited to servers, all 8 time zones valid
/timestamp 19:30 New York in October gives 23:30 UTC and in January 00:30 UTC the next day; Berlin in summer is UTC+2; a bad date gets the private format hint
/lfg and buttons Join, double join, join when full, host leave, stranger leave, leave reopening Join, close by a non-host, close by the host, clicks after close ignored
Cooldown Second /lfg within 60 seconds gets the private cooldown message
Timeout With the interaction token expired, the post is still edited through the channel endpoint with every button disabled; a 403 on that edit only logs a warning
Errors An error before responding uses the first response; an error after responding sends a private followup; the traceback is logged; a button error reaches View.on_error
main() Run from another folder: .env is still found, the guild sync runs, on_ready logs, and there is no Message Content warning; a missing token or server ID exits with a clear message; a wrong token raises LoginFailure

What this doesn't prove is how Discord's servers treat your particular server, permissions and account, which is what the troubleshooting table covers.

Troubleshooting

What you see Likely cause and fix
The commands don't appear You didn't run --sync guild, DEV_GUILD_ID is another server, or the Discord app on your device hasn't refreshed yet. Restart the Discord app.
Every command appears twice Global and guild copies of the same commands. Run python bot.py --sync clear-guild.
403 Forbidden when syncing The bot isn't in the server in DEV_GUILD_ID, or it was invited long ago with a link that lacked the applications.commands scope. Invite it with the link from step 2.
"This interaction failed" The bot isn't running, it took longer than 3 seconds to answer, or the buttons belong to a post from before a restart (see the next section).
CommandSignatureMismatch in the log You changed a command without syncing. Run --sync guild (or --sync global in production).
LoginFailure: Improper token has been passed. The token in .env is wrong or was reset. Reset it in the portal and paste the new one.
PrivilegedIntentsRequired Your code asks for an intent that is off in the portal. Turn it on there, or remove it from the code.
ModuleNotFoundError: No module named 'discord' The virtual environment isn't active. Run the activate command from step 3 again.
An AttributeError saying module 'discord' has no attribute 'Intents' You named one of your files discord.py, which hides the real library. Rename it.
ZoneInfoNotFoundError on Windows tzdata isn't installed. Run pip install -r requirements.txt.

If your bot is more tangled than this table covers, my guide to a Discord bot that isn't working goes through the general causes.

Running the bot 24/7

The bot only answers while python bot.py is running, so it stops when your computer sleeps. To keep it online, run it on a machine that is always on: a small Linux VPS, or a host that runs long-lived processes. Hosts built for websites that sleep when idle are a poor fit, because a gateway bot has to keep one connection open to Discord all the time. My guide to hosting a Discord bot 24/7 compares the free and paid options.

On a Linux VPS, systemd can start the bot at boot and restart it after a crash. This minimal unit assumes a bot user and the project in /home/bot/pybot; adjust both:

/etc/systemd/system/pybot.service
[Unit]
Description=Discord bot (discord.py)
After=network-online.target
Wants=network-online.target

[Service]
User=bot
WorkingDirectory=/home/bot/pybot
ExecStart=/home/bot/pybot/.venv/bin/python bot.py
Restart=on-failure
RestartSec=10

[Install]
WantedBy=multi-user.target

Enable it with sudo systemctl enable --now pybot and read the logs with journalctl -u pybot -f.

What a production bot adds

This bot is a sound base, and it is deliberately small. These are the gaps I close before a server depends on a bot:

  • Buttons that survive restarts. The LFG view lives in memory, so after a restart old buttons show "This interaction failed". A persistent view uses timeout=None, a fixed custom_id on every button and bot.add_view() in setup_hook; discord.py's persistent.py example shows the pattern. The player list then has to live in a database too.
  • A database. SQLite is enough for one server; PostgreSQL once several processes or a web dashboard share the data.
  • Per-server settings. A multi-server bot stores its channels and roles per server, usually set with an admin command, instead of reading one server's IDs from .env.
  • Alerts, not just logs. Unexpected errors sent to a private channel or a monitoring service, so you hear about them before your members do.
  • Updates. Discord changes the API and discord.py follows. Someone has to read the changelog and upgrade.

If you'd rather have that built, I build custom bots in discord.py or discord.js on your own bot account, with the code handed over. If you already have a discord.py bot that broke or went offline, Bot Rescue fixes it for a fixed $149, or you pay nothing. For a new bot, send a short brief and I'll come back with a fixed quote.

Questions

Is discord.py still maintained in 2026?

Yes. Version 2.7.0 was released on February 27, 2026 and 2.7.1 on March 3, 2026, according to PyPI. The 2.x line supports slash commands, buttons, modals and Discord's newer layout components.

Is Python or JavaScript better for Discord bots?

Neither is better for Discord itself: both libraries call the same API and support the same features. Pick the language you already know, or the one your other code is in. The discord.js vs discord.py comparison goes through the differences that matter.

How do I make a slash command admin-only in discord.py?

Add @app_commands.default_permissions(manage_guild=True) under the @app_commands.command line and sync again. Discord then hides the command from members without Manage Server, and server admins can still override that per role or channel. For a hard rule inside your code, add @app_commands.checks.has_permissions as well.

Can a slash command option have more than 25 choices?

No. Discord allows at most 25 choices per option, so longer lists need autocomplete, which suggests values as the member types. An option can't have both choices and autocomplete.

Is making a Discord bot hard?

A first working command is a small job if you know some Python; the bot in this guide is about 300 lines. The harder parts come later: keeping it online, storing data safely, buttons that survive restarts, and updating when Discord changes things. That upkeep is what my Care plans cover.

Sources

Prices and features were checked on September 27, 2026.

  1. discord.py on PyPI (2.7.1, released March 3, 2026)
  2. discord.py docs, Introduction (Python version, installing)
  3. discord.py docs, Interactions API reference (CommandTree, app_commands, ui.View)
  4. discord.py docs, Extensions
  5. discord.py docs, Frequently asked questions
  6. discord.py docs, Changelog
  7. discord.py examples, app_commands/basic.py
  8. discord.py examples, views/persistent.py
  9. discord.py issue 9856, Invalid Webhook Token when editing after the token expires
  10. AbstractUmbra, Application command basics (don't auto-sync)
  11. Discord Developer Docs, Application commands (limits, guild vs global)
  12. Discord Developer Docs, Receiving and responding to interactions (3 seconds, 15-minute tokens)
  13. Discord Developer Docs, Gateway (intents, privileged intents)
  14. Discord Developer Docs, Getting started with privileged intent review (10,000-user threshold)
  15. Discord Developer Docs, You might not need a privileged intent
  16. Discord Developer Docs, OAuth2 (bot scope includes applications.commands)
  17. Discord Developer Docs, Permissions
  18. Discord Developer Docs, Message resource (Edit Message)
  19. Discord Developer Docs, Reference (timestamp styles)
  20. Python docs, venv
  21. Python docs, zoneinfo (tzdata on Windows)
  22. python-dotenv on PyPI

Tell me what your server needs

Send a short brief. You get a reply within one business day with questions or a price range, and a fixed quote before any work starts.