Guide

Organising a discord.py bot with cogs

A single bot.py is fine until it is 900 lines long. Cogs are discord.py's way of splitting it into files, and the only tricky part is the order things load in.

8 minute read

For discord.py 2.x. Two words that get used loosely: a cog is a class that groups commands and listeners; an extension is a Python file that discord.py can load, which usually adds one cog.

The layout

my-bot/
  bot.py
  requirements.txt
  cogs/
    __init__.py      (empty)
    fun.py
    moderation.py

A cog

# cogs/fun.py
import random

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

class Fun(commands.Cog):
    def __init__(self, bot: commands.Bot):
        self.bot = bot

    @app_commands.command(description="Roll a die")
    async def roll(self, interaction: discord.Interaction, sides: int = 6):
        await interaction.response.send_message(f"You rolled {random.randint(1, sides)}")

    @commands.command()
    async def hello(self, ctx: commands.Context):
        await ctx.send(f"Hello {ctx.author.mention}")

    @commands.Cog.listener()
    async def on_member_join(self, member: discord.Member):
        channel = member.guild.system_channel
        if channel:
            await channel.send(f"Welcome, {member.mention}!")

async def setup(bot: commands.Bot):
    await bot.add_cog(Fun(bot))
  • Every command and listener takes self first. Forgetting it gives confusing errors about the wrong number of arguments.
  • Slash commands in a cog use @app_commands.command, not @bot.tree.command. Prefix commands use @commands.command.
  • Events use @commands.Cog.listener(), and the method name is the event name.
  • The setup function at the bottom is what makes the file an extension. It must be async in discord.py 2.
  • on_member_join needs the Server Members intent in your code and in the Developer Portal.

Loading them

# bot.py
import os

import discord
from discord.ext import commands

EXTENSIONS = ["cogs.fun", "cogs.moderation"]

class MyBot(commands.Bot):
    def __init__(self):
        intents = discord.Intents.default()
        intents.message_content = True   # for prefix commands
        intents.members = True           # for on_member_join
        super().__init__(command_prefix="!", intents=intents)

    async def setup_hook(self):
        for ext in EXTENSIONS:
            await self.load_extension(ext)
        await self.tree.sync()   # after loading, or the cogs' slash commands are missing

bot = MyBot()
bot.run(os.environ["BOT_TOKEN"])

The order matters. tree.sync() uploads whatever commands exist at that moment. Sync before loading the extensions and Discord gets a list without them.

Extension names are module paths with dots (cogs.fun), not file paths (cogs/fun.py). They are resolved from the folder the bot is started in, which is why a bot that works when run from its own folder fails when started from somewhere else.

Loading every file in the folder

import pathlib

async def setup_hook(self):
    for path in sorted(pathlib.Path(__file__).parent.joinpath("cogs").glob("*.py")):
        if path.stem != "__init__":
            await self.load_extension(f"cogs.{path.stem}")
    await self.tree.sync()

Reloading without restarting

An owner-only command that reloads a cog after you edit it:

@commands.command()
@commands.is_owner()
async def reload(self, ctx: commands.Context, name: str):
    await self.bot.reload_extension(f"cogs.{name}")
    await ctx.send(f"Reloaded {name}")

Reloading picks up changed Python code. It does not re-sync slash commands: if you added, renamed or removed one, run a sync as well (see slash commands in discord.py). If the bot's hosting pulls from Git on restart, a plain restart does both.

Shared things: a database, an HTTP session

Create shared objects once on the bot, and reach them from any cog through self.bot:

import aiohttp

class MyBot(commands.Bot):
    async def setup_hook(self):
        self.http_session = aiohttp.ClientSession()
        for ext in EXTENSIONS:
            await self.load_extension(ext)
        await self.tree.sync()

    async def close(self):
        await self.http_session.close()
        await super().close()

Then in a cog: async with self.bot.http_session.get(url) as r: .... Opening a new session for every command leaks connections and eventually memory.

Errors you will meet

  • ExtensionNotFound: Extension 'cogs.fun' could not be loaded: the path is wrong, or the bot was started from another folder. Check the folder has __init__.py and that you run python bot.py from my-bot.
  • NoEntryPointError: the file has no setup function, or it is not async.
  • ExtensionAlreadyLoaded: load_extension ran twice, often because it was put in on_ready, which can fire more than once. Use setup_hook.
  • RuntimeWarning: coroutine 'Bot.add_cog' was never awaited: old discord.py 1.x code. In 2.x both add_cog and load_extension need await.
  • Slash commands from a cog missing: synced before loading, or the cog raised an error while loading that was scrolled past. Read the console from the top of the start.