Make a Discord bot in Python

From nothing to a bot with slash commands, replies, embeds and buttons in your server. The first part takes about twenty minutes. You need Python installed and a Discord server you can add bots to.

15 min readBeginnerUpdated Examples tested with discord.py 2.7.1

1. Create the application

  1. Go to the Discord Developer Portal and press New Application. The name is what people will see.
  2. Open Bot. Press Reset Token and copy it. This is the bot's password: anyone with it can run your bot. Keep it somewhere private for now.
  3. On the same page, Privileged Gateway Intents can all stay off for this guide. You only need Message Content Intent if your bot will read ordinary messages, for example !commands.

2. Invite it to your server

  1. Open OAuth2, then URL Generator.
  2. Tick bot and applications.commands.
  3. Under bot permissions, tick Send Messages (and whatever else your bot will need later).
  4. Open the URL at the bottom, pick your server, and authorise.

The bot shows up in the member list, offline. It comes online when your code runs.

3. Set up the project

You need Python 3.10 or newer (python --version). Make a folder and a virtual environment, so this bot's packages stay separate from everything else:

Terminal
mkdir my-bot
cd my-bot
python -m venv venv
# Windows:        venv\Scripts\activate
# macOS / Linux:  source venv/bin/activate
pip install discord.py python-dotenv
pip freeze > requirements.txt

4. Keep the token out of the code

Create a file called .env in the folder:

.env
BOT_TOKEN=paste-your-token-here

If you use Git, add .env and venv/ to .gitignore now, before your first commit. Tokens pushed to public repositories are found by scanners within minutes.

5. Write the bot

Create bot.py:

Python
import os
import random

import discord
from discord import app_commands
from dotenv import load_dotenv

load_dotenv()

class MyBot(discord.Client):
    def __init__(self):
        super().__init__(intents=discord.Intents.default())
        self.tree = app_commands.CommandTree(self)

    async def setup_hook(self):
        # Tell Discord which slash commands exist. Runs once per start.
        await self.tree.sync()

bot = MyBot()

@bot.event
async def on_ready():
    print(f"Logged in as {bot.user}")

@bot.tree.command(description="Say hello")
async def hello(interaction: discord.Interaction):
    await interaction.response.send_message(f"Hello, {interaction.user.mention}!")

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

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

6. Run it

Terminal
python bot.py

You should see Logged in as and your bot's name, and the bot turns green in Discord. Type / in your server: /hello and /roll are listed under your bot. The first time, it can take a minute, and reloading Discord (Ctrl+R) helps.

7. Make new commands appear instantly while you test

self.tree.sync() registers commands globally, for every server the bot is in, and Discord can take a while to show changes. While you are building, register them to your own server instead, where they update the moment you restart:

Python
MY_GUILD = discord.Object(id=123456789012345678)  # your server's ID

class MyBot(discord.Client):
    ...
    async def setup_hook(self):
        self.tree.copy_global_to(guild=MY_GUILD)
        await self.tree.sync(guild=MY_GUILD)

To find the ID, turn on Developer Mode in Discord (User Settings, Advanced), then right-click your server and choose Copy Server ID. When the bot is ready for other servers, go back to self.tree.sync(). If every command shows up twice, you have both: a global copy and a server copy.

8. Reply to ordinary messages

Slash commands need nothing special. Reading what people type in chat does: it needs the Message Content intent, switched on in two places. In the Developer Portal, under Bot, turn on Message Content Intent. In the code, ask for it:

Python
class MyBot(discord.Client):
    def __init__(self):
        intents = discord.Intents.default()
        intents.message_content = True
        super().__init__(intents=intents)
        self.tree = app_commands.CommandTree(self)

    async def setup_hook(self):
        await self.tree.sync()  # keep this from step 5, or the slash commands stop registering

@bot.event
async def on_message(message: discord.Message):
    if message.author.bot:
        return  # never answer bots, including yourself
    if message.content.lower() == "ping":
        await message.channel.send("pong")

Without the intent, message.content is empty for most messages and the bot looks as if it ignores everyone. Asking for it in code but not switching it on in the portal stops the bot at login with PrivilegedIntentsRequired.

9. Send an embed

Embeds are the boxed messages with a title, fields and a colour bar:

Python
@bot.tree.command(description="Show some facts about this server")
@app_commands.guild_only()
async def serverinfo(interaction: discord.Interaction):
    guild = interaction.guild
    embed = discord.Embed(title=guild.name, colour=discord.Colour.blurple())
    embed.add_field(name="Members", value=str(guild.member_count))
    embed.add_field(name="Created", value=discord.utils.format_dt(guild.created_at, "D"))
    if guild.icon:
        embed.set_thumbnail(url=guild.icon.url)
    await interaction.response.send_message(embed=embed)

@app_commands.guild_only() hides the command in DMs, where there is no server to describe and interaction.guild would be None.

10. Add a button

Buttons live in a View. This one counts its clicks and edits its own label:

Python
class Counter(discord.ui.View):
    def __init__(self):
        super().__init__(timeout=180)  # stops answering after 3 minutes
        self.count = 0

    @discord.ui.button(label="Clicked 0 times", style=discord.ButtonStyle.primary)
    async def click(self, interaction: discord.Interaction, button: discord.ui.Button):
        self.count += 1
        button.label = f"Clicked {self.count} times"
        await interaction.response.edit_message(view=self)

@bot.tree.command(description="A button that counts its clicks")
async def counter(interaction: discord.Interaction):
    await interaction.response.send_message("Press it:", view=Counter())

A view lives in the bot's memory, so its buttons stop working after the timeout or a restart. Buttons that must keep working for days are called persistent views; the discord.py examples show how.

Slash commands or prefix commands?

Prefix commands are the older !ping style, from discord.ext.commands:

Python
from discord.ext import commands

intents = discord.Intents.default()
intents.message_content = True
bot = commands.Bot(command_prefix="!", intents=intents)

@bot.command()
async def ping(ctx: commands.Context):
    await ctx.send("pong")

They need the Message Content intent. A bot in fewer than 100 servers can switch it on itself; past 100, Discord grants it only after a review. Slash commands need neither, show up in Discord's command menu with their options, and are what new bots should use. commands.Bot also has a bot.tree, so a bot can have both while it moves over. Once a bot has more than a handful of commands, split them into files with cogs.

If it did not work

  • KeyError: 'BOT_TOKEN': the .env file is not in the folder you ran the command from, or the line is misspelt.
  • LoginFailure: Improper token has been passed: you copied the Client Secret or the Application ID instead of the token, or reset the token afterwards. Reset it and copy it again.
  • ModuleNotFoundError: No module named 'discord': the virtual environment is not active in this terminal. Activate it again.
  • PrivilegedIntentsRequired: the code asks for an intent (usually Message Content) that is switched off in the Developer Portal, under Bot.
  • The bot ignores ordinary messages: the Message Content intent is missing in the code, in the portal, or both. See step 8.
  • Commands do not appear: the bot was invited without applications.commands. Make a new invite link with both scopes and open it again. More in slash commands in discord.py.

Where to go next

When you want it running all the time, SnowServers runs exactly this kind of bot from $3 a month, with a 7-day free trial: upload the folder (without venv and .env) or push it to GitHub, paste the token into the panel, and it stays online. Hosting a discord.py bot walks through it.

Run it here. Flurry keeps a bot like this online all day for $3 a month, and the first week is free.

Start the free week

Press / or Ctrl K on any page to search.