pyguides

Build a Discord Bot with discord.py

Building a Discord bot is one of the most satisfying first projects for Python developers who want to ship something a real person can interact with. You write Python, the bot stays online in the cloud, and your friends can use it on a server tonight. The discord.py library makes this surprisingly short, but it does have a few sharp edges: intents, async, and the difference between old-style prefix commands and modern slash commands. This guide walks through all of it, from a 15-line “hello” bot to one that responds to !ping and /ping alike.

You will need Python 3.8 or newer, a Discord account, and about 15 minutes.

Why discord.py and what you’ll build

discord.py is the official Python wrapper for the Discord API, maintained by Danny “Rapptz” and a community of contributors. It exposes the same WebSocket gateway that the official Discord client uses, so anything you can do in the Discord UI you can automate from Python: read messages, send replies, manage roles, ban users, post embeds, handle voice, and so on.

A “Discord bot” in this guide means a long-running Python process that connects to Discord’s gateway and reacts to events. By the end you will have a bot that:

  • Replies to a classic !ping prefix command with the current latency.
  • Replies to the modern /ping slash command with the same value.
  • Loads its token from an environment variable, not a hardcoded string.

Install discord.py and create a bot account

Install the library with pip. The -U flag pulls the latest stable release:

pip install -U discord.py

The package on PyPI is discord.py (note the dot), and you import it as discord. There is a different package on PyPI called discord that is unrelated, so make sure you install the one with a dot.

Next, create the bot account. Open the Discord Developer Portal, click New Application, give it a name, then jump to the Bot tab and click Add Bot. Copy the token. This is the bot’s password, and you should treat it like one. Anyone with the token can run your bot.

You also need to invite the bot to your server. In the Developer Portal, go to OAuth2 → URL Generator, tick the bot and applications.commands scopes, pick the permissions you want (start with Send Messages and Read Message History), and open the generated URL in a browser. Pick a server you admin and authorize.

Two more steps, both of which matter more than they look:

  1. On the Bot tab, scroll down to Privileged Gateway Intents and enable Message Content Intent. You will need this to read the text of any message.

  2. In your project folder, create a .env file that holds the token:

    # .env
    DISCORD_TOKEN=your-token-here

    Add .env to your .gitignore immediately. Committing this file to a public repo means anyone reading GitHub can take over your bot.

A minimal bot with discord.py

The shortest possible bot uses discord.Client and listens for messages directly:

# example_bot.py
import discord

intents = discord.Intents.default()
intents.message_content = True

client = discord.Client(intents=intents)

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

@client.event
async def on_message(message):
    if message.author == client.user:
        return
    if message.content.startswith("$hello"):
        await message.channel.send("Hello!")

client.run("your-token-here")

Three things to notice in those 15 lines.

Every event handler is async def. discord.py is built on asyncio; if you forget async the bot refuses to start. If async and await feel new, skim the async/await patterns guide first.

intents.message_content = True is required, but not sufficient. You also need the privileged intent enabled in the Developer Portal. If you forget either half, your bot connects and looks healthy, but every message.content check silently returns the empty string. This is the single most common cause of “my bot just sits there” reports.

on_message fires for every message the bot can see, including the bot’s own replies. The first line of any on_message handler should be if message.author == client.user: return, otherwise your bot responds to its own “Hello!” with another “Hello!” and you get an infinite loop. The same rule applies to prefix-command bots, just with a different client.

client.run() is a blocking call. Anything you write after it never runs, so put all setup before it or in setup_hook() (covered below).

Move to the commands framework

discord.Client is fine for tiny bots, but the moment you have more than one command you want commands.Bot, a subclass of Client that adds a command framework, automatic help text, converters (so int and discord.Member annotations Just Work), and error handling.

# bot.py
import os
import discord
from discord.ext import commands
from dotenv import load_dotenv

load_dotenv()

intents = discord.Intents.default()
intents.message_content = True

bot = commands.Bot(command_prefix="!", intents=intents)

@bot.command()
async def ping(ctx):
    """Replies with the bot's WebSocket latency."""
    latency_ms = round(bot.latency * 1000)
    await ctx.send(f"Pong! {latency_ms}ms")

@bot.command()
async def add(ctx, a: int, b: int):
    await ctx.send(f"{a} + {b} = {a + b}")

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

A few details that trip people up the first time:

ctx is mandatory. Every @bot.command callback takes a commands.Context as its first parameter. If you forget it, the bot starts and Python raises TypeError: missing 1 required positional argument: 'ctx' the first time someone runs the command.

Parameter names become command arguments. !add 2 3 calls add(ctx, a=2, b=3). Type annotations like int are converters, so the framework raises a clean BadArgument error if the user types !add foo bar.

For multi-word arguments, use keyword-only parameters with a leading *:

@bot.command()
async def echo(ctx, *, text: str):
    await ctx.send(text)

With *, !echo hello world works as expected. Without it, the user would have to type !echo "hello world", and the second word would arrive as a separate argument you did not declare.

Slash commands with bot.tree

Discord’s modern way of doing commands is the slash command. Users type /ping, Discord shows a small UI with descriptions, and the framework handles argument types. In discord.py 2.x you define slash commands on bot.tree, a CommandTree that is pre-built on every commands.Bot.

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

load_dotenv()

intents = discord.Intents.default()
intents.message_content = True

bot = commands.Bot(command_prefix="!", intents=intents)

@bot.tree.command(name="ping", description="Replies with the bot's latency")
async def ping(interaction: discord.Interaction):
    latency_ms = round(bot.latency * 1000)
    await interaction.response.send_message(f"Pong! {latency_ms}ms")

@bot.event
async def on_ready():
    # Sync once at startup. Global sync can take up to an hour to propagate.
    # For instant iteration, use: await bot.tree.sync(guild=discord.Object(id=GUILD_ID))
    try:
        synced = await bot.tree.sync()
        print(f"Synced {len(synced)} slash command(s)")
    except Exception as e:
        print(f"Failed to sync: {e}")

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

Three things to keep in mind.

Slash command names are restricted: lowercase, 1 to 32 characters, and only letters, digits, underscores, and hyphens. Discord rejects anything else with an HTTP 400.

bot.tree.sync() is rate-limited. Call it once at startup, not inside on_message or on every message. Repeated sync calls get you throttled. For development, sync to a single test guild (where propagation is instant) with await bot.tree.sync(guild=discord.Object(id=YOUR_GUILD_ID)). Switch to global sync before you ship.

The first parameter of a slash callback is a discord.Interaction, not a Context. You reply with interaction.response.send_message(...) for the initial response, then use interaction.followup.send(...) for follow-up messages after the 15-minute interaction token expires.

Reading message content and working with intents

Intents are Discord’s opt-in list of “things the gateway will send you.” discord.Intents.default() enables a safe subset, but it does not enable message_content, which is the one almost every bot needs.

intents = discord.Intents.default()
intents.message_content = True

message_content is a privileged intent, one of three Discord gates behind a manual toggle in the Developer Portal (the others are members and presence). You must enable it in the portal, and you must set the flag in code. Skip either half and the bot will connect, but message.content will be an empty string for every message.

If you only need slash commands and do not need to read arbitrary message text, you can leave the intent off entirely. Slash command argument values arrive through the gateway regardless.

Handling errors gracefully

When a user types !add with no arguments, the commands framework raises commands.MissingRequiredArgument and shows nothing by default. You can catch that:

@bot.event
async def on_command_error(ctx, error):
    if isinstance(error, commands.CommandNotFound):
        return
    if isinstance(error, commands.MissingRequiredArgument):
        await ctx.send(f"Missing argument: `{error.param.name}`")
    elif isinstance(error, commands.BadArgument):
        await ctx.send("That argument didn't parse. Check the type and try again.")
    else:
        # Re-raise so the default traceback still appears in your logs
        raise error

commands.CommandNotFound fires for every message that starts with the prefix but does not name a real command. Most bots swallow it silently. If you do not, your error channel will fill with spam from !whatever. Returning early is the right move.

For per-command error handling, define a method with the same name plus _error:

@bot.command()
async def add(ctx, a: int, b: int):
    await ctx.send(f"{a} + {b} = {a + b}")

@add.error
async def add_error(ctx, error):
    if isinstance(error, commands.BadArgument):
        await ctx.send("Both arguments must be numbers.")

The global on_command_error runs after the local one, so you can combine both: handle command-specific cases in the local handler and let everything else fall through to the global one.

Where to go next

You now have a working Discord bot that responds to both !ping and /ping, reads its token from a .env file, and handles the most common errors. To take it further:

  • Cogs for organization. Once you have more than a handful of commands, split them into cogs/ modules and load them with await bot.load_extension("cogs.moderation"). Each cog is a class subclassing commands.Cog.
  • Hybrid commands for free parity. @bot.hybrid_command(name="ping") defines a command that works as both !ping and /ping from a single definition. It is the easiest way to support both old and new clients during a migration.
  • Embeds for richer messages. await ctx.send(embed=discord.Embed(title="Hello", description="World")) produces a card-style message that looks much nicer in busy channels.
  • Logging. Replace print(f"Logged in as {bot.user}") with the standard logging module so you can route output to files or external services. The logging guide covers the pattern.

Conclusion

A Discord bot in discord.py is a few things at once: an asyncio client, a webhook of gateway events, and (if you use commands.Bot) a small command framework. The path from zero to “responds to !ping” is short, and the path from there to “responds to /ping and !ping with the same definition” is shorter than it looks. Start with the minimal discord.Client example, switch to commands.Bot as soon as you have a second command, and add slash commands once the bot.tree examples make sense. You will learn more from running a real bot for a week than from any number of tutorials.

See also