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 plaincogsfolder sitting next toapp.pyis 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.commandis a slash command — the only difference from the single-file version is the leadingself. - The
setupfunction at the bottom is whatload_extensioncalls. Every cog file must defineasync def setup(bot)andadd_cogits 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:
- Create
cogs/yourname.pywith aCogclass and itssetupfunction. - Add one line to
setup_hook:await self.load_extension("cogs.yourname"). - 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.pyto work on admin commands, not scroll a 600-line module. - Failures are local. A broken cog fails on its own
load_extensionline, 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.funmeanscogs/fun.py, spelled exactly, sitting next toapp.py. Check the folder and file names.- Commands don't appear in Discord — either
setup_hookdoesn't callawait self.tree.sync(), or it's the normal first-time global delay. Sync insetup_hook, restart, wait a few minutes. ModuleNotFoundError: No module named 'cogs'— thecogsfolder isn't besideapp.py, where the app runs from. You do not need an__init__.py— a plaincogsfolder 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'tadd_cog-ing the class. Without that call, the file registers nothing.
Next steps
- Store data for your bot
- Slash commands in depth — the discord.js side of the same ideas
- Python on Falix