Get your free server today! View Plans →
Home Plans Blog About Contact Panel Join Discord
Discord Bots

Organizing a discord.py bot with cogs

Tired of scrolling through one huge bot.py file? This tutorial shows you how to split a discord.py bot into cogs with a clean folder layout, auto-loading, shared config, and live reloads.

Organizing a discord.py bot with cogs

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:

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:

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.

ErrorLikely cause and fix
ExtensionFailed / module has no setup functionYour 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.
ExtensionNotFoundThe dotted path is wrong. cogs/moderation.py loads as "cogs.moderation", with a dot, not a slash, and no .py.
RuntimeWarning: coroutine was never awaitedYou forgot await on load_extension or add_cog. Both are async in discord.py 2.x.
Commands run but on_member_join never firesMissing 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:

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.

Common questions

What is a cog in discord.py?

A cog is a Python class that inherits from commands.Cog and groups related commands, listeners, and helper methods into one file. You load it into the bot at startup, and you can reload or unload it on its own.

Why does my cog fail to load with 'has no setup function'?

Every cog file needs an async setup function at the bottom that calls await bot.add_cog(YourCog(bot)). If that function is missing or is not async, discord.py cannot load the file.

How do I reload a cog without restarting the whole bot?

Add an owner-only command that calls await self.bot.reload_extension('cogs.yourcog'). After you edit the file, run the command and the new code goes live while the bot stays connected.

Where should I load cogs in discord.py 2.x?

Load them inside the async setup_hook method on your bot subclass. It runs once after login but before the bot is ready, and it is async, so await load_extension works there.

How do I share a database or config between cogs?

Attach shared, mutable things like a database pool or HTTP session to the bot instance in setup_hook, then reach them from any cog through self.bot. Keep static settings in a config.py module that each cog can import.

PN
Priya Nair
Community Manager at Bytte.cloud

Part of the Bytte.cloud team. We run game servers, bots and websites for a living, and we write these guides from what we see day to day in support and on our own servers.

Want to try this on real hardware?

Bytte.cloud has free plans for game servers, bots and websites. No credit card, set up in seconds.

Start for free See the plans