Discord bots run as web services that use Discord’s API. They listen for requests from Discord and send responses. They need their own databases if a way to persist data is necessary. However, some Discord bot features require saving some data about the bot in Discord’s servers, such as slash command names.

Automated testing of Discord bots tends to be very limited or nonexistent since Discord has rate limits and is too complex to practically mock. Creating Discord bots requires a lot of trial and error, but speeds up as you learn. It’s fun since you can get a simple bot up and running fairly quickly, and the visual feedback you get from each improvement can be very satisfying. Now that I’ve built a multi-purpose Discord bot, I’ve been able to create new, specialized bots within only a few days each when the opportunity arises since I mostly understand the API and have code to copy.

There are Discord API wrappers for many languages. The one I’m familiar with is Rapptz/discord.py: An API wrapper for Discord written in Python.

Regardless of which tools you use, creating a Discord bot requires creating the bot’s account in the Discord Developer Portal. Setting up your own local test instance of a bot you’re working on makes manual testing and debugging much easier. For each bot I work on, I create an extra Discord bot account in the Discord Developer Portal for testing.

Project structure

There are multiple ways to structure the code for bots written with discord.py, but I’ll describe the way that seems most common among well-written bots. The entrypoint is main.py, which only connects to the database, creates an instance of the bot, and starts the bot. In bot.py, a Bot class is defined that subclasses commands.Bot. This class has no commands. It only handles global events like logging, error handling, cooldowns (command rate limits), and managing cogs. Cogs are in a folder named cogs, which has a folder named utils containing various utilities shared among the cogs.

Cogs

Cogs are like modules that group related commands together. They can be loaded, unloaded, and reloaded while the bot is running. Most Discord bots only take a few seconds to start up, but some take 30 minutes or even more because of how many servers they’re in. For those, being able to change a cog’s code and reload the cog without restarting the bot can be very helpful. A cog that many bots have is Owner, which groups all commands that only the bot’s owner can use. By subclassing commands.Cog and defining the cog_check method, you can easily keep your administrative commands secure:

import os
import sys
from discord.ext import commands  # https://pypi.org/project/discord.py/


class Owner(commands.Cog):
    """Commands that can only be used by the bot owner."""

    def __init__(self, bot) -> None:
        self.bot = bot

    async def cog_check(self, ctx):
        if not await self.bot.is_owner(ctx.author):
            raise commands.NotOwner
        return True

    @commands.hybrid_command()
    async def restart(self, ctx):
        """Restarts the bot"""
        await ctx.send("Restarting")
        python = sys.executable
        os.execl(python, python, *sys.argv)


async def setup(bot):
    await bot.add_cog(Owner(bot))

Type annotations and docstrings

Python’s type annotations and docstrings usually have no runtime effect, but discord.py uses them for some features. As an example, I copied part of wheelercj/GitHub-bot’s /issue list command below. The list_issues method has three parameters after the ctx parameter: repo_name, assignee, and state. Since these are parameters of a command’s function, they are parameters of the command. They each have a default value, so they are all optional. The Literal["open", "closed", "all"] tells Discord to require one of those three strings to be entered if the user enters anything for the state option. The repo_name, assignee, and state parameters are described in the docstring following numpydoc’s docstring standard. Discord takes those descriptions and displays them when the user is entering options if the command is used as a slash command. They also appear in command help pages if you aren’t using slash commands.

    @commands.hybrid_group()
    async def issue(self, ctx: commands.Context):
        """A group of commands for managing GitHub issues"""

    @issue.command(name="list", aliases=["ls"])
    async def list_issues(
        self,
        ctx: commands.Context,
        repo_name: str | None = None,
        assignee: str | None = None,
        state: Literal["open", "closed", "all"] = "open",
    ):
        """Shows a repo's GitHub issues

        Parameters
        ----------
        repo_name: str | None
            Filter repos by name, or part of the name.
        assignee: str | None
            Filter issues by the GitHub user assigned.
        state: Literal["open", "closed", "all"]
            Only show "open", "closed", or "all" issues. Defaults to "open".
        """

Next steps

One must learn by doing the thing, for though you think you know it, you have no certainty until you try.

— Sophocles, 5th century B.C.

There’s a lot about Discord bots that I’m not covering here because you will learn so much faster with experimentation, looking at examples like the ones at the end of this page, and asking questions. That’s why the rest of this post will just be very specific tips and tricks. If you’re new to working on Discord bots and you’re joining an existing project, it may be worth it to create a small Discord bot of your own so you understand more of the framework.

Interactions

An interaction must be responded to exactly once and within 3 seconds, or an error will occur. If more time and/or multiple responses will be needed, you should use defer, and then followup within 15 minutes. If you have a ctx available, you can use await ctx.defer() (with ephemeral=True if you want the first deferred response to be ephemeral), and await ctx.send(your_message) will followup. Modals cannot be deferred.

As of 2025-03-12, modals can only contain text inputs, and views cannot contain text inputs. A single modal can have up to 5 text inputs. A single select (a dropdown menu) can have up to 25 options. As far as I know, Discord’s developers have not said whether these will ever change.

The long and paragraph text input styles are identical both visually and functionally.

Syncing slash commands

To add slash commands to a bot, after writing the code for them, you need to sync the commands to Discord. In other words, you have to save some info about the slash commands in Discord’s servers, such as the command names.

If your bot uses jishaku, you can use:

  • jsk sync $ to sync all global slash commands globally
  • jsk sync . to sync any slash commands that are just for the current Discord server
  • jsk sync * to sync server slash commands to all known servers

Jishaku can give you feedback in most cases if there’s a problem with your slash command data. Discord has a bunch of rules about slash commands, such as how long the command descriptions are. There’s also a rate limit on syncing commands, and Discord is usually vague about what their rate limits are.

If you ever end up with two of each slash command, there are a few things you can try that might help:

  • press Ctrl+R (Mac: Cmd+R) to reload Discord
  • kick your bot from the server and add it again
  • wait a minute to see if Discord’s servers were just temporarily inconsistent
  • try syncing again, but remember that there’s a rate limit
  • if you somehow synced global commands globally and global commands to the current server, sync the current server’s commands to the current server (I’m not sure if this problem is possible with Jishaku’s sync commands)

Ephemeral messages

Messages can be ephemeral, which means that they can only be seen by their sender and receiver, and will eventually disappear. Bots can only send ephemeral messages in response to interactions, such as slash commands.

You can send an ephemeral message with await ctx.send("Secret message", ephemeral=True). However, you must not use it with async with ctx.typing(): or else the message will not be ephemeral. Apparently, using ctx.typing counts as sending a non-ephemeral message, and ctx.send kind of “edits” that “message”, but cannot edit whether it is ephemeral. Fortunately, interactions already show a loading indicator anyways, so using ctx.typing should not be necessary.

Interaction exception handling

Any exceptions raised in view or modal callbacks are passed to that object’s on_error method, but nowhere else. It is recommended to subclass discord.ui.View, implement on_error, and subclass that view so you can handle all view errors in one place. Same for modals.

Examples