Intermediate
Discord bots handle everything from moderation and music playback to game statistics and custom notifications. If you have spent any time in an active Discord server, you have almost certainly interacted with one. Building your own bot gives you a way to automate repetitive tasks, add server utilities, or create entirely new community experiences — and with Python’s discord.py library, the barrier to entry is surprisingly low.
You will need Python 3.10+, a Discord account, and the discord.py library. Install it with pip install discord.py. You will also create a free Discord bot application in the Discord Developer Portal — the walkthrough below takes about three minutes. No paid API is required.
This article covers: creating a bot application in the Developer Portal, connecting to Discord with discord.py, responding to messages and events, adding slash commands, sending rich embeds, and a real-life moderation bot you can deploy to your own server today.
Your First Discord Bot: Quick Example
Once you have your bot token from the Developer Portal (we cover that in the next section), this minimal bot connects to Discord and replies to any message that says “!ping”:
# discord_quick.py
import discord
from discord.ext import commands
intents = discord.Intents.default()
intents.message_content = True # Required to read message text
bot = commands.Bot(command_prefix="!", intents=intents)
@bot.event
async def on_ready():
print(f"Logged in as {bot.user} (ID: {bot.user.id})")
@bot.command()
async def ping(ctx):
await ctx.send(f"Pong! Latency: {round(bot.latency * 1000)}ms")
bot.run("YOUR_BOT_TOKEN_HERE")
Output (terminal):
Logged in as MyBot#1234 (ID: 1234567890123456789)
Type !ping in any Discord channel the bot has access to and it replies with the current latency. The @bot.command() decorator registers the function as a bot command automatically. Every handler is an async function because all Discord operations are I/O-bound network calls.
Setting Up the Bot in the Developer Portal
Before writing any code you need to create a bot application and get a token. Here is the exact sequence:
1. Go to discord.com/developers/applications and click New Application. Give it a name.
2. Click the Bot tab in the left sidebar, then click Add Bot. Confirm the dialog.
3. Under Token, click Reset Token and copy the token immediately — Discord only shows it once. Store it in a .env file, never in source code.
4. Under Privileged Gateway Intents, enable Message Content Intent if your bot needs to read message text.
5. Under OAuth2 > URL Generator, select the bot and applications.commands scopes, choose the permissions you need (at minimum: Send Messages, Read Message History), and use the generated URL to invite the bot to your server.
# discord_env.py -- load the token safely from .env
import discord
from discord.ext import commands
from dotenv import load_dotenv
import os
load_dotenv() # Reads .env file: BOT_TOKEN=your_token_here
TOKEN = os.getenv("BOT_TOKEN")
intents = discord.Intents.default()
intents.message_content = True
bot = commands.Bot(command_prefix="!", intents=intents)
@bot.event
async def on_ready():
print(f"Ready: {bot.user}")
bot.run(TOKEN)
Output:
Ready: MyBot#1234
Create a file named .env in the same directory with the line BOT_TOKEN=paste_your_token_here. Install python-dotenv with pip install python-dotenv. This pattern keeps your token out of version control.
Slash Commands with discord.app_commands
Slash commands (the /command style) are the modern way to add bot commands. They show up in Discord’s autocomplete UI and can be registered globally or to a specific server (guild). Use the @bot.tree.command() decorator and call await bot.tree.sync() once to register them.
# discord_slash.py
import discord
from discord import app_commands
from discord.ext import commands
from dotenv import load_dotenv
import os
load_dotenv()
intents = discord.Intents.default()
bot = commands.Bot(command_prefix="!", intents=intents)
@bot.event
async def on_ready():
await bot.tree.sync() # Register slash commands with Discord
print(f"Synced commands. Bot is {bot.user}")
@bot.tree.command(name="hello", description="Say hello to the bot")
async def hello(interaction: discord.Interaction):
await interaction.response.send_message(
f"Hello, {interaction.user.mention}! I am alive and running."
)
@bot.tree.command(name="roll", description="Roll a die (default: d6)")
@app_commands.describe(sides="Number of sides on the die")
async def roll(interaction: discord.Interaction, sides: int = 6):
import random
result = random.randint(1, sides)
await interaction.response.send_message(f"Rolling d{sides}... you got **{result}**!")
bot.run(os.getenv("BOT_TOKEN"))
Usage in Discord:
/hello
>> Hello, @YourName! I am alive and running.
/roll sides:20
>> Rolling d20... you got 17!
The @app_commands.describe decorator adds descriptions for each parameter that Discord shows in the autocomplete dropdown. Call bot.tree.sync() once to register commands; after that they persist until you change them. Global sync can take up to an hour to propagate; guild-specific sync (pass guild=discord.Object(id=YOUR_GUILD_ID)) is instant and better for development.
Handling Events
Discord bots are event-driven: the library calls your handler functions when something happens — a member joins, a message is deleted, a reaction is added. Decorate any async function with @bot.event and name it on_<event_name>.
# discord_events.py
import discord
from discord.ext import commands
from dotenv import load_dotenv
import os
load_dotenv()
intents = discord.Intents.default()
intents.members = True # Requires Members Intent in Developer Portal
intents.message_content = True
bot = commands.Bot(command_prefix="!", intents=intents)
@bot.event
async def on_member_join(member: discord.Member):
channel = member.guild.system_channel
if channel:
await channel.send(f"Welcome to the server, {member.mention}! Read the rules in #rules.")
@bot.event
async def on_message_delete(message: discord.Message):
if message.author.bot:
return
log_channel = discord.utils.get(message.guild.text_channels, name="mod-log")
if log_channel:
await log_channel.send(
f"Message deleted in {message.channel.mention} by {message.author}: "
f'"{message.content[:100]}"'
)
bot.run(os.getenv("BOT_TOKEN"))
Output (terminal, on member join):
# Bot posts in the system channel:
"Welcome to the server, @NewUser! Read the rules in #rules."
Enable the Members Privileged Intent in the Developer Portal for on_member_join to fire. Event handlers run asynchronously so they will not block each other. Use discord.utils.get() to look up channels by name — it is cleaner than iterating the channel list manually.
Sending Rich Embeds
Plain text messages are functional, but Discord embeds let you add titles, thumbnails, color bars, and fields. They look far more polished and are easy to build with the discord.Embed class.
# discord_embed.py
import discord
from discord.ext import commands
from dotenv import load_dotenv
import os
load_dotenv()
intents = discord.Intents.default()
bot = commands.Bot(command_prefix="!", intents=intents)
@bot.command()
async def info(ctx):
embed = discord.Embed(
title="Server Info",
description=f"Stats for **{ctx.guild.name}**",
color=discord.Color.blurple()
)
embed.add_field(name="Members", value=str(ctx.guild.member_count), inline=True)
embed.add_field(name="Channels", value=str(len(ctx.guild.channels)), inline=True)
embed.add_field(name="Created", value=ctx.guild.created_at.strftime("%b %d, %Y"), inline=True)
embed.set_thumbnail(url=ctx.guild.icon.url if ctx.guild.icon else "")
embed.set_footer(text="Powered by discord.py")
await ctx.send(embed=embed)
bot.run(os.getenv("BOT_TOKEN"))
Output (Discord message):
[Embed card]
Server Info
Stats for My Awesome Server
Members: 142 | Channels: 23 | Created: Jan 15, 2024
[Server icon thumbnail]
Powered by discord.py
Set inline=True on fields to display them side by side (up to 3 per row). Use discord.Color.blurple() for Discord’s signature purple or pass a hex value like discord.Color(0x00ff88) for custom colors. Embeds are limited to 6,000 total characters across all fields.
Real-Life Example: Basic Moderation Bot
This bot combines slash commands, embeds, and event handling into a simple moderation helper that can warn, kick, and log deletions.
# discord_mod_bot.py
import discord
from discord import app_commands
from discord.ext import commands
from dotenv import load_dotenv
import os
from datetime import datetime
load_dotenv()
intents = discord.Intents.default()
intents.message_content = True
intents.members = True
bot = commands.Bot(command_prefix="!", intents=intents)
@bot.event
async def on_ready():
await bot.tree.sync()
print(f"{bot.user} is online")
@bot.tree.command(name="warn", description="Warn a member (mod only)")
@app_commands.describe(member="Member to warn", reason="Reason for the warning")
@app_commands.default_permissions(kick_members=True)
async def warn(interaction: discord.Interaction, member: discord.Member, reason: str = "No reason given"):
embed = discord.Embed(
title="Warning Issued",
color=discord.Color.orange(),
timestamp=datetime.utcnow()
)
embed.add_field(name="Member", value=member.mention)
embed.add_field(name="Reason", value=reason)
embed.set_footer(text=f"Warned by {interaction.user}")
await interaction.response.send_message(embed=embed)
try:
await member.send(f"You received a warning in **{interaction.guild.name}**: {reason}")
except discord.Forbidden:
pass # Member has DMs disabled
@bot.tree.command(name="kick", description="Kick a member (mod only)")
@app_commands.describe(member="Member to kick", reason="Reason for the kick")
@app_commands.default_permissions(kick_members=True)
async def kick(interaction: discord.Interaction, member: discord.Member, reason: str = "No reason given"):
await member.kick(reason=reason)
embed = discord.Embed(title="Member Kicked", color=discord.Color.red())
embed.add_field(name="Member", value=str(member))
embed.add_field(name="Reason", value=reason)
await interaction.response.send_message(embed=embed)
@bot.event
async def on_message_delete(message: discord.Message):
if message.author.bot:
return
log_ch = discord.utils.get(message.guild.text_channels, name="mod-log")
if log_ch and message.content:
await log_ch.send(
f"**Deleted** in {message.channel.mention} by {message.author.mention}: "
f"`{message.content[:200]}`"
)
bot.run(os.getenv("BOT_TOKEN"))
Output (Discord, /warn command):
[Orange embed]
Warning Issued
Member: @SpamUser | Reason: Posting invite links
Warned by @Moderator
The @app_commands.default_permissions(kick_members=True) decorator restricts the slash command to users who have the Kick Members permission, so regular members cannot use it. The try/except discord.Forbidden around the DM handles the common case where a member has DMs disabled. Extend this bot by adding a warnings database (SQLite works well) to track repeat offenders.
Frequently Asked Questions
Why do I get an error about Intents?
Discord requires you to declare which gateway intents (event types) your bot uses. The most common missing intent is message_content — required to read message text in servers. Enable it in your code with intents.message_content = True and also in the Discord Developer Portal under your bot’s settings page (under Privileged Gateway Intents). Skipping either step results in your bot receiving blank message content.
My slash commands are not appearing — what is wrong?
Call await bot.tree.sync() inside your on_ready event. Global command sync can take up to an hour to propagate to all Discord clients. For faster testing during development, sync to a specific guild: await bot.tree.sync(guild=discord.Object(id=YOUR_GUILD_ID)) — this is instant. Only call sync() once at startup, not on every command invocation.
How do I keep the bot running 24/7?
On a VPS or cloud server (AWS EC2, DigitalOcean Droplet, Railway.app), run the bot as a background service with systemd or a process manager like pm2. For free hosting, Railway.app and Fly.io both have free tiers that work well for lightweight bots. For development, simply run the script in your terminal and leave it running — it blocks until you press Ctrl+C.
How does discord.py handle rate limits?
discord.py handles Discord’s rate limits automatically. When you hit a rate limit, the library pauses the coroutine and retries the request after the reset period. You will see a warning in your logs but your bot will not crash. Avoid sending large volumes of messages in tight loops; instead, batch operations or add small delays with await asyncio.sleep(1) between bulk operations.
What are Cogs and when should I use them?
A Cog is a class-based way to organize commands and event listeners into separate files. When your bot grows beyond 50-100 lines, you should split it into Cogs — one file per feature area (moderation, music, fun commands, etc.). Create a class that inherits from commands.Cog, move your commands into it as methods, and load it with await bot.load_extension("cog_filename"). This keeps your main bot file clean and makes each feature independently testable.
Conclusion
You now have everything you need to build and deploy a Discord bot with Python. The key building blocks are: the commands.Bot class and its event loop, the @bot.event decorator for event-driven responses, @bot.tree.command() for slash commands with type-safe parameters, and discord.Embed for rich formatted messages. The moderation bot example shows how these pieces combine into a real, usable tool.
Good next steps: add a SQLite database to persist warnings and ban records, implement a music player using discord.FFmpegPCMAudio, or add a game statistics tracker that pulls from a public API. The discord.py documentation at discordpy.readthedocs.io covers every event type and API method in detail.