By the end of this tutorial you'll have a discord.py bot split into clean, separate files called cogs, with a small folder layout that scales as you add features. It's written for anyone who started with a single bot.py file and now finds it impossible to work in. You don't need to be an expert, just comfortable running Python and editing a few files.
Why one giant bot.py file stops working
Almost every discord.py bot starts the same way. You make a file, paste in the quickstart, add a command, then another, then an event listener or two. It works, so you keep going. Six months later that file is 1,500 lines long and you're scrolling for ten seconds just to find the command you want to edit.
The real problems show up when you try to change something. Everything lives in the same scope, so a tiny edit to your music commands can break your moderation commands. You can't restart just one part of the bot. And if you ever want a friend to help, handing them a single huge file is rough. We hit this exact wall on an internal bot we run for support tickets, and moving to cogs fixed it in an afternoon.
What a cog actually is
A cog is just a Python class that groups related commands, listeners, and helper methods together. Think of it as one feature folder turned into one class. You might have a Moderation cog, a Fun cog, and an Economy cog. Each lives in its own file, gets loaded into the bot at startup, and can be unloaded or reloaded on its own.
What cogs give you in practice:
- Each feature sits in its own file, so finding code is fast.
- You can reload one cog while the bot is running, without restarting everything.
- Commands and the listeners that support them live next to each other.
- Shared setup (like a database connection) is passed in once and used everywhere.
This tutorial uses discord.py 2.x, which is the current line. Check your version first.
python -m pip install -U discord.py
python -c "import discord; print(discord.__version__)"
You want to see something like 2.4.0 or newer. If you see a 1.x version, upgrade before continuing, because the loading code below uses the newer async style.
A sensible folder structure
Here's the layout we'll build. It's small on purpose. You can grow it later.
my-bot/
bot.py
config.py.env
cogs/
__init__.py
moderation.py
fun.py
utils/
__init__.py
checks.py
requirements.txt
A few notes on what each piece does:
- bot.py is the entry point. It creates the bot, loads every cog, and starts the connection.
- config.py reads your token and any settings from the environment.
- .env holds secrets like the bot token. Never commit this file.
- cogs/ holds one file per feature.
- utils/ holds shared helpers, like a permission check you reuse in several cogs.
The two empty __init__.py files just mark those folders as Python packages so imports behave predictably.
Step 1: Set up config and secrets
Create a .env file in the project root and put your token in it. You get this token from the Discord Developer Portal under your application's Bot tab.
#.env
DISCORD_TOKEN=your-token-goes-here
Install the small library that reads it.
python -m pip install python-dotenv
Now make config.py to load that value once and fail loudly if it's missing.
# config.py
import os
from dotenv import load_dotenv
load_dotenv()
TOKEN = os.getenv("DISCORD_TOKEN")
if not TOKEN:
raise RuntimeError("DISCORD_TOKEN is not set. Check your.env file.")
# A guild id you can use for fast slash command syncing while testing
TEST_GUILD_ID = int(os.getenv("TEST_GUILD_ID", "0")) or None
Reading the token from the environment keeps it out of your code, which matters the moment you push to GitHub.
Step 2: Write your first cog
Create cogs/moderation.py. A cog is a class that inherits from commands.Cog. It takes the bot instance in its constructor and stores it, so every command in the class can reach the bot.
# cogs/moderation.py
import discord
from discord.ext import commands
class Moderation(commands.Cog):
def __init__(self, bot: commands.Bot):
self.bot = bot
@commands.command(name="kick")
@commands.has_permissions(kick_members=True)
async def kick(self, ctx: commands.Context, member: discord.Member, *, reason: str = "No reason given"):
await member.kick(reason=reason)
await ctx.send(f"Kicked {member.mention}. Reason: {reason}")
# A listener inside a cog uses @commands.Cog.listener()
@commands.Cog.listener()
async def on_member_join(self, member: discord.Member):
print(f"{member} joined {member.guild.name}")
async def setup(bot: commands.Bot):
await bot.add_cog(Moderation(bot))
Two details matter here. First, commands inside a cog take self as the first argument, because they're methods. Second, every cog file needs an async setup function at the bottom. That function is what discord.py calls when you load the file. It creates the cog and adds it to the bot.
Make a second cog so the structure is real. Create cogs/fun.py.
# cogs/fun.py
import random
from discord.ext import commands
class Fun(commands.Cog):
def __init__(self, bot: commands.Bot):
self.bot = bot
@commands.command(name="roll")
async def roll(self, ctx: commands.Context, sides: int = 6):
if sides < 2:
await ctx.send("A die needs at least 2 sides.")
return
result = random.randint(1, sides)
await ctx.send(f"You rolled a {result} on a d{sides}.")
async def setup(bot: commands.Bot):
await bot.add_cog(Fun(bot))
Step 3: Load the cogs from bot.py
Now the entry point. The key piece is setup_hook, a method discord.py runs after login but before the bot is fully ready. It's the correct place to load extensions, because it's async and runs once.
# bot.py
import asyncio
import discord
from discord.ext import commands
import config
INITIAL_COGS = [
"cogs.moderation",
"cogs.fun",
]
class MyBot(commands.Bot):
def __init__(self):
intents = discord.Intents.default()
intents.message_content = True # needed for prefix commands
intents.members = True # needed for on_member_join and kicks
super().__init__(command_prefix="!", intents=intents)
async def setup_hook(self):
for cog in INITIAL_COGS:
await self.load_extension(cog)
print(f"Loaded {cog}")
async def on_ready(self):
print(f"Logged in as {self.user} (id {self.user.id})")
async def main():
bot = MyBot()
async with bot:
await bot.start(config.TOKEN)
if __name__ == "__main__":
asyncio.run(main())
Notice how the cogs are listed as dotted paths, not file paths. cogs.moderation means the file cogs/moderation.py. The load_extension call imports that module and runs its setup function for you.
Run it.
python bot.py
You should see output like this:
Loaded cogs.moderation
Loaded cogs.fun
Logged in as MyBot#1234 (id 112233445566778899)
Now type !roll 20 in a channel the bot can see. It should reply with a number. The two features are running, but they live in separate files. That's the whole point.
Step 4: Auto-load every cog in the folder
Listing cogs by hand gets old. You can have the bot scan the cogs folder and load anything it finds, so adding a feature is just dropping in a new file.
async def setup_hook(self):
import os
for filename in os.listdir("cogs"):
if filename.endswith(".py") and not filename.startswith("_"):
name = f"cogs.{filename[:-3]}"
await self.load_extension(name)
print(f"Loaded {name}")
The filename[:-3] chops off the .py so you're left with the module name. Skipping files that start with an underscore keeps __init__.py out of the list. With this in place you can delete the INITIAL_COGS list entirely.
Step 5: Share the bot instance and config across cogs
Every cog already holds self.bot, so anything you attach to the bot is reachable from all of them. This is the clean way to share a database pool, an HTTP session, or settings. Attach it once in setup_hook.
async def setup_hook(self):
# Example: one shared aiohttp session for all cogs
import aiohttp
self.session = aiohttp.ClientSession()
# load cogs after, so they can rely on self.bot.session...
Inside any cog you then reach it through self.bot.session. The same trick works for config values. Because config.py is just a module, any cog can import config and read config.TEST_GUILD_ID without passing anything around. Keep shared, mutable state on the bot, and keep static settings in config.py. That split keeps things easy to follow.
Step 6: Reload a cog while developing
This is the feature that makes cogs worth it day to day. You can edit a cog, reload just that cog, and test the change without restarting the bot or losing its connection. Add a small owner-only command to do it.
# add this inside any cog, or a small dev cog
@commands.command(name="reload")
@commands.is_owner()
async def reload(self, ctx: commands.Context, cog: str):
try:
await self.bot.reload_extension(f"cogs.{cog}")
await ctx.send(f"Reloaded cogs.{cog}")
except commands.ExtensionError as e:
await ctx.send(f"Failed: {e}")
Now run !reload fun after editing fun.py and the new code is live. The @commands.is_owner() check makes sure only you can run it. If reloading throws an error, the old version of the cog stays loaded, so a typo won't take your bot down.
Sharing a permission check with the utils folder
When two cogs need the same logic, put it in utils/. A common case is a custom permission check. Create utils/checks.py.
# utils/checks.py
from discord.ext import commands
def is_in_guild(guild_id: int):
async def predicate(ctx: commands.Context):
return ctx.guild is not None and ctx.guild.id == guild_id
return commands.check(predicate)
Then any cog can import and use it.
from utils.checks import is_in_guild
@commands.command()
@is_in_guild(123456789012345678)
async def secret(self, ctx):
await ctx.send("This only works in one server.")
That's the value of the utils folder. Write the helper once, use it everywhere, and your cogs stay focused on their own commands.
Troubleshooting
These are the errors that trip people up most when they first move to cogs.
| Error | Likely cause and fix |
|---|---|
| ExtensionFailed / module has no setup function | Your cog file is missing the async setup function at the bottom, or it isn't async. Every cog file needs async def setup(bot): await bot.add_cog(...). |
| ModuleNotFoundError: No module named 'cogs' | You're running the bot from the wrong directory, or the __init__.py files are missing. Run python bot.py from the project root. |
| ExtensionNotFound | The dotted path is wrong. cogs/moderation.py loads as "cogs.moderation", with a dot, not a slash, and no .py. |
| RuntimeWarning: coroutine was never awaited | You forgot await on load_extension or add_cog. Both are async in discord.py 2.x. |
| Commands run but on_member_join never fires | Missing intents. Set intents.members = True in your bot, and enable Server Members Intent in the Developer Portal. |
A few more pointers from real debugging sessions:
- If setup_hook never seems to run, make sure you didn't override
__init__without callingsuper().__init__(). The bot needs that base setup to function. - An import error inside a cog stops only that cog from loading, not the whole bot. Read the full traceback the print line gives you. It names the exact file and line.
- If a reload says the extension isn't loaded, you probably never loaded it the first time. Use
load_extensionfor the first load andreload_extensionafter. - Getting "command already registered" usually means two cogs define a command with the same name. Rename one.
Where to take it next
You now have a bot that grows by adding files instead of growing one file forever. The pattern stays the same no matter how big the bot gets: one feature per cog, shared things attached to the bot, helpers in utils, and a reload command so you barely ever restart. When you're ready to host it somewhere it stays online around the clock, a small VPS or a bot hosting plan (Bytte.cloud runs Python bots on a panel that handles restarts and logs for you) makes deployment simple. Start by splitting your current bot into two cogs today, get the loading working, and add the rest one file at a time.



