Organize a discord.py bot with cogs

One app.py is fine for /ping — past that it becomes a wall of code. Cogs split related commands into their own files, loaded at startup. Here's the exact three-file layout.

A single app.py is fine for a /ping bot. Add ten commands and it turns into a wall you have to scroll. discord.py's answer is cogs — classes that group related commands into their own files, loaded when the bot starts. This guide restructures a bot into a small Bot subclass plus two cog files, and shows how to keep growing from there.

At a glance
You need comfort with the discord.py bot guide, a bot token from Your first Discord bot, and a Falix server running the Python application
Time about twenty minutes

This replaces the single-file layout with a tidier one.

The shape: three files

app.py
cogs/
  fun.py
  admin.py

app.py boots the bot and loads the cogs; each file in cogs/ is one cog — a group of related commands.

🎯 Good to know: No special project setup and no __init__.py — a plain cogs folder sitting next to app.py is all discord.py needs.

app.py: a Bot that loads its cogs

The single-file guide used discord.Client plus a CommandTree. Cogs are easier on commands.Bot, which carries a command tree of its own:

import os

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

load_dotenv()


class MyBot(commands.Bot):
    def __init__(self):
        super().__init__(command_prefix="!", intents=discord.Intents.default())

    async def setup_hook(self):
        await self.load_extension("cogs.fun")
        await self.load_extension("cogs.admin")
        await self.tree.sync()


bot = MyBot()


@bot.event
async def on_ready():
    print(f"Listening as {bot.user}", flush=True)


bot.run(os.environ["DISCORD_TOKEN"])

setup_hook runs once, after login but before the bot is ready — the right place to load extensions and sync. Each load_extension("cogs.fun") imports cogs/fun.py; the dot is a folder separator, so cogs.fun means the file cogs/fun.py. tree.sync() then pushes every command the cogs registered up to Discord. (command_prefix is required by commands.Bot even for a slash-only bot — "!" is a harmless default you'll never actually use.)

A cog: cogs/fun.py

A cog is a class that inherits commands.Cog. Its command methods take self as well as the interaction:

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(name="ping", description="Check that the bot is alive")
    async def ping(self, interaction: discord.Interaction):
        latency = round(self.bot.latency * 1000)
        await interaction.response.send_message(f"Pong! {latency} ms")

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


async def setup(bot: commands.Bot):
    await bot.add_cog(Fun(bot))

Two required parts:

  • The class holds the commands. Each method decorated with @app_commands.command is a slash command — the only difference from the single-file version is the leading self.
  • The setup function at the bottom is what load_extension calls. Every cog file must define async def setup(bot) and add_cog its class. Without it, the extension loads nothing.

A second cog: cogs/admin.py

Same pattern, a different theme:

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


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

    @app_commands.command(name="say", description="Make the bot repeat a message")
    @app_commands.describe(message="What the bot should say")
    async def say(self, interaction: discord.Interaction, message: str):
        await interaction.response.send_message(message)


async def setup(bot: commands.Bot):
    await bot.add_cog(Admin(bot))

A typed parameter (message: str) automatically becomes a slash-command option; @app_commands.describe gives that option its description.

Adding another cog

Three steps, every time:

  1. Create cogs/yourname.py with a Cog class and its setup function.
  2. Add one line to setup_hook: await self.load_extension("cogs.yourname").
  3. Restart. tree.sync() runs on start, so the new commands register (allow the usual first-time delay).

Reloading a changed cog

discord.py can hot-swap a single cog while the bot stays connected: bot.reload_extension("cogs.fun") (alongside load_extension and unload_extension) re-imports just that one file without a full restart. It's a real feature — but you trigger it by typing a command into a terminal or an owner-only command, and a Falix server gives you no interactive shell to type into. So the practical move here is the simpler one: edit the file and restart the server. setup_hook runs again, every cog reloads, and tree.sync() re-registers commands — the same fresh state, one click.

State lives on the cog

Because each command is a method, the cog instance is a natural home for data. Store it as an attribute in __init__ and every command reads it through self:

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

    @app_commands.command(name="count", description="Count up")
    async def count(self, interaction: discord.Interaction):
        self.counter += 1
        await interaction.response.send_message(f"Count is now {self.counter}")

self.bot is the bot itself — handy for latency, fetching channels, or reaching shared services — and self.counter is this cog's own data, tidily separate from every other cog.

💡 Tip: In-memory state resets on restart — see Store data for your bot to persist it.

Why this beats one giant file

  • Related commands sit together. You open cogs/admin.py to work on admin commands, not scroll a 600-line module.
  • Failures are local. A broken cog fails on its own load_extension line, naming the file — far easier than hunting a syntax error in one huge file.
  • Cogs are self-contained. Each owns its state and helpers, so unrelated commands stop stepping on each other.

Troubleshooting

  • ExtensionNotFound: Extension 'cogs.fun' could not be found — the dotted path doesn't match a file. cogs.fun means cogs/fun.py, spelled exactly, sitting next to app.py. Check the folder and file names.
  • Commands don't appear in Discord — either setup_hook doesn't call await self.tree.sync(), or it's the normal first-time global delay. Sync in setup_hook, restart, wait a few minutes.
  • ModuleNotFoundError: No module named 'cogs' — the cogs folder isn't beside app.py, where the app runs from. You do not need an __init__.py — a plain cogs folder works; this error means the folder is misplaced or misnamed, not missing a package marker.
  • The extension loads but a command is missing — the file's async def setup(bot) isn't add_cog-ing the class. Without that call, the file registers nothing.

Next steps

Was this guide helpful?