Build a Discord Bot with Python: From Zero to Deployed
Build a feature-rich Discord bot with Python and discord.py -- slash commands, embeds, moderation, database integration, and VPS deployment.
Discord bots are one of the most satisfying things to build. You write code, and within minutes it's live in a server, responding to real people in real time. No deployment pipeline, no DNS configuration, no waiting for DNS propagation. Just code and immediate feedback.
Whether you want to automate moderation, build a music bot, create a game, or just make something fun for your server, discord.py gives you everything you need. This tutorial takes you from zero to a deployed utility bot with slash commands, embeds, moderation features, and persistent data.
Setting Up the Discord Developer Portal
Before writing any code, you need a bot account:
- Go to the Discord Developer Portal
- Click "New Application" and give it a name
- Go to the "Bot" tab and click "Add Bot"
- Under "Privileged Gateway Intents", enable:
- Click "Reset Token" and copy your bot token. Store it somewhere safe -- you can't see it again.
- Go to "OAuth2" > "URL Generator"
- Select scopes:
bot,applications.commands - Select permissions:
Send Messages,Manage Messages,Embed Links,Read Message History,Use Slash Commands, and whatever else your bot needs - Copy the generated URL and open it in your browser to invite the bot to your test server
Project Setup
mkdir discord-bot
cd discord-bot
python -m venv venv
source venv/bin/activate # Windows: venv\Scripts\activate
pip install discord.py python-dotenv aiosqlite
Create a .env file:
DISCORD_TOKEN=your-bot-token-here
And a .gitignore:
.env
venv/
__pycache__/
*.db
Your First Bot
# bot.py
import discord
from discord.ext import commands
from dotenv import load_dotenv
import os
load_dotenv()
# Create bot instance with intents
intents = discord.Intents.default()
intents.message_content = True
intents.members = True
bot = commands.Bot(
command_prefix="!",
intents=intents,
description="A utility bot built with discord.py"
)
@bot.event
async def on_ready():
print(f"Logged in as {bot.user} (ID: {bot.user.id})")
print(f"Connected to {len(bot.guilds)} server(s)")
# Sync slash commands
synced = await bot.tree.sync()
print(f"Synced {len(synced)} slash command(s)")
@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}! "
f"You're member #{member.guild.member_count}."
)
# Simple prefix command
@bot.command(name="ping")
async def ping(ctx: commands.Context):
latency = round(bot.latency * 1000)
await ctx.send(f"Pong! Latency: {latency}ms")
bot.run(os.getenv("DISCORD_TOKEN"))
Run it:
python bot.py
Your bot should come online. Type !ping in your server to test it.
Slash Commands
Slash commands are the modern way to interact with Discord bots. They show up in the autocomplete menu and have built-in parameter types:
from discord import app_commands
@bot.tree.command(name="roll", description="Roll a dice")
@app_commands.describe(sides="Number of sides on the dice", count="How many dice to roll")
async def roll(interaction: discord.Interaction, sides: int = 6, count: int = 1):
if sides < 2 or sides > 100:
await interaction.response.send_message("Sides must be between 2 and 100.", ephemeral=True)
return
if count < 1 or count > 20:
await interaction.response.send_message("Count must be between 1 and 20.", ephemeral=True)
return
import random
rolls = [random.randint(1, sides) for _ in range(count)]
total = sum(rolls)
result = ", ".join(str(r) for r in rolls)
await interaction.response.send_message(
f"Rolling {count}d{sides}: [{result}] = {total}"
)
@bot.tree.command(name="serverinfo", description="Get information about this server")
async def serverinfo(interaction: discord.Interaction):
guild = interaction.guild
embed = discord.Embed(
title=guild.name,
description=guild.description or "No description",
color=discord.Color.blue()
)
embed.set_thumbnail(url=guild.icon.url if guild.icon else "")
embed.add_field(name="Owner", value=guild.owner.mention, inline=True)
embed.add_field(name="Members", value=str(guild.member_count), inline=True)
embed.add_field(name="Channels", value=str(len(guild.channels)), inline=True)
embed.add_field(name="Roles", value=str(len(guild.roles)), inline=True)
embed.add_field(name="Created", value=guild.created_at.strftime("%B %d, %Y"), inline=True)
embed.add_field(name="Boost Level", value=str(guild.premium_tier), inline=True)
embed.set_footer(text=f"Server ID: {guild.id}")
await interaction.response.send_message(embed=embed)
The ephemeral=True parameter makes the response visible only to the user who ran the command. Use this for error messages and sensitive information.
Rich Embeds
Embeds are how you make bot responses look professional:
@bot.tree.command(name="userinfo", description="Get information about a user")
@app_commands.describe(member="The user to look up")
async def userinfo(interaction: discord.Interaction, member: discord.Member = None):
member = member or interaction.user
roles = [role.mention for role in member.roles[1:]] # Skip @everyone
embed = discord.Embed(
title=f"User Info: {member.display_name}",
color=member.color if member.color != discord.Color.default() else discord.Color.green()
)
embed.set_thumbnail(url=member.display_avatar.url)
embed.add_field(name="Username", value=str(member), inline=True)
embed.add_field(name="ID", value=str(member.id), inline=True)
embed.add_field(name="Status", value=str(member.status).title(), inline=True)
embed.add_field(
name="Joined Server",
value=member.joined_at.strftime("%B %d, %Y") if member.joined_at else "Unknown",
inline=True
)
embed.add_field(
name="Account Created",
value=member.created_at.strftime("%B %d, %Y"),
inline=True
)
embed.add_field(
name=f"Roles ({len(roles)})",
value=", ".join(roles) if roles else "None",
inline=False
)
await interaction.response.send_message(embed=embed)
Moderation Commands
Let's add some real utility -- moderation tools with proper permission checks:
@bot.tree.command(name="kick", description="Kick a member from the server")
@app_commands.describe(member="The member to kick", reason="Reason for the kick")
@app_commands.checks.has_permissions(kick_members=True)
async def kick(interaction: discord.Interaction, member: discord.Member, reason: str = "No reason provided"):
if member.top_role >= interaction.user.top_role:
await interaction.response.send_message(
"You can't kick someone with a higher or equal role.", ephemeral=True
)
return
if member == interaction.guild.owner:
await interaction.response.send_message(
"You can't kick the server owner.", ephemeral=True
)
return
try:
await member.send(f"You've been kicked from {interaction.guild.name}. Reason: {reason}")
except discord.Forbidden:
pass # Can't DM the user, that's fine
await member.kick(reason=f"{reason} (by {interaction.user})")
embed = discord.Embed(
title="Member Kicked",
color=discord.Color.orange(),
description=f"{member.mention} has been kicked."
)
embed.add_field(name="Reason", value=reason)
embed.add_field(name="Moderator", value=interaction.user.mention)
await interaction.response.send_message(embed=embed)
@bot.tree.command(name="ban", description="Ban a member from the server")
@app_commands.describe(member="The member to ban", reason="Reason for the ban", delete_days="Days of messages to delete (0-7)")
@app_commands.checks.has_permissions(ban_members=True)
async def ban(interaction: discord.Interaction, member: discord.Member, reason: str = "No reason provided", delete_days: int = 0):
if member.top_role >= interaction.user.top_role:
await interaction.response.send_message(
"You can't ban someone with a higher or equal role.", ephemeral=True
)
return
delete_days = max(0, min(7, delete_days))
try:
await member.send(f"You've been banned from {interaction.guild.name}. Reason: {reason}")
except discord.Forbidden:
pass
await member.ban(reason=f"{reason} (by {interaction.user})", delete_message_days=delete_days)
embed = discord.Embed(
title="Member Banned",
color=discord.Color.red(),
description=f"{member.mention} has been banned."
)
embed.add_field(name="Reason", value=reason)
embed.add_field(name="Moderator", value=interaction.user.mention)
await interaction.response.send_message(embed=embed)
@bot.tree.command(name="clear", description="Delete messages from the channel")
@app_commands.describe(amount="Number of messages to delete (1-100)")
@app_commands.checks.has_permissions(manage_messages=True)
async def clear(interaction: discord.Interaction, amount: int = 10):
if amount < 1 or amount > 100:
await interaction.response.send_message("Amount must be between 1 and 100.", ephemeral=True)
return
await interaction.response.defer(ephemeral=True)
deleted = await interaction.channel.purge(limit=amount)
await interaction.followup.send(f"Deleted {len(deleted)} messages.", ephemeral=True)
Error Handling
Handle command errors gracefully:
@bot.tree.error
async def on_app_command_error(interaction: discord.Interaction, error: app_commands.AppCommandError):
if isinstance(error, app_commands.MissingPermissions):
missing = ", ".join(error.missing_permissions)
await interaction.response.send_message(
f"You need the following permissions: {missing}", ephemeral=True
)
elif isinstance(error, app_commands.CommandOnCooldown):
await interaction.response.send_message(
f"This command is on cooldown. Try again in {error.retry_after:.1f}s.", ephemeral=True
)
else:
await interaction.response.send_message(
"Something went wrong. Please try again later.", ephemeral=True
)
print(f"Command error: {error}")
@bot.event
async def on_command_error(ctx: commands.Context, error: commands.CommandError):
if isinstance(error, commands.CommandNotFound):
return # Silently ignore unknown commands
elif isinstance(error, commands.MissingRequiredArgument):
await ctx.send(f"Missing argument: {error.param.name}")
elif isinstance(error, commands.BadArgument):
await ctx.send("Invalid argument. Please check your input.")
else:
await ctx.send("An error occurred.")
print(f"Error: {error}")
Database Integration
For persistent data, use SQLite with aiosqlite (async-compatible):
# database.py
import aiosqlite
DB_PATH = "bot.db"
async def init_db():
async with aiosqlite.connect(DB_PATH) as db:
await db.execute("""
CREATE TABLE IF NOT EXISTS warnings (
id INTEGER PRIMARY KEY AUTOINCREMENT,
guild_id INTEGER NOT NULL,
user_id INTEGER NOT NULL,
moderator_id INTEGER NOT NULL,
reason TEXT NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
""")
await db.execute("""
CREATE TABLE IF NOT EXISTS tags (
name TEXT NOT NULL,
guild_id INTEGER NOT NULL,
content TEXT NOT NULL,
author_id INTEGER NOT NULL,
uses INTEGER DEFAULT 0,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (name, guild_id)
)
""")
await db.commit()
async def add_warning(guild_id: int, user_id: int, moderator_id: int, reason: str) -> int:
async with aiosqlite.connect(DB_PATH) as db:
cursor = await db.execute(
"INSERT INTO warnings (guild_id, user_id, moderator_id, reason) VALUES (?, ?, ?, ?)",
(guild_id, user_id, moderator_id, reason)
)
await db.commit()
return cursor.lastrowid
async def get_warnings(guild_id: int, user_id: int) -> list:
async with aiosqlite.connect(DB_PATH) as db:
db.row_factory = aiosqlite.Row
cursor = await db.execute(
"SELECT * FROM warnings WHERE guild_id = ? AND user_id = ? ORDER BY created_at DESC",
(guild_id, user_id)
)
return await cursor.fetchall()
async def save_tag(name: str, guild_id: int, content: str, author_id: int):
async with aiosqlite.connect(DB_PATH) as db:
await db.execute(
"INSERT OR REPLACE INTO tags (name, guild_id, content, author_id) VALUES (?, ?, ?, ?)",
(name, guild_id, content, author_id)
)
await db.commit()
async def get_tag(name: str, guild_id: int) -> dict | None:
async with aiosqlite.connect(DB_PATH) as db:
db.row_factory = aiosqlite.Row
cursor = await db.execute(
"SELECT * FROM tags WHERE name = ? AND guild_id = ?",
(name, guild_id)
)
row = await cursor.fetchone()
if row:
await db.execute(
"UPDATE tags SET uses = uses + 1 WHERE name = ? AND guild_id = ?",
(name, guild_id)
)
await db.commit()
return dict(row) if row else None
Now use it in your bot:
from database import init_db, add_warning, get_warnings, save_tag, get_tag
@bot.event
async def on_ready():
await init_db()
print(f"Database initialized")
# ... rest of on_ready
@bot.tree.command(name="warn", description="Warn a member")
@app_commands.describe(member="The member to warn", reason="Reason for the warning")
@app_commands.checks.has_permissions(moderate_members=True)
async def warn(interaction: discord.Interaction, member: discord.Member, reason: str):
warning_id = await add_warning(
interaction.guild.id, member.id, interaction.user.id, reason
)
warnings = await get_warnings(interaction.guild.id, member.id)
embed = discord.Embed(
title="Warning Issued",
color=discord.Color.yellow()
)
embed.add_field(name="Member", value=member.mention, inline=True)
embed.add_field(name="Warning #", value=str(len(warnings)), inline=True)
embed.add_field(name="Reason", value=reason, inline=False)
embed.set_footer(text=f"Warning ID: {warning_id}")
await interaction.response.send_message(embed=embed)
try:
await member.send(
f"You received a warning in {interaction.guild.name}: {reason}\n"
f"Total warnings: {len(warnings)}"
)
except discord.Forbidden:
pass
@bot.tree.command(name="warnings", description="View warnings for a member")
@app_commands.describe(member="The member to check")
async def warnings_cmd(interaction: discord.Interaction, member: discord.Member):
warnings = await get_warnings(interaction.guild.id, member.id)
if not warnings:
await interaction.response.send_message(
f"{member.mention} has no warnings.", ephemeral=True
)
return
embed = discord.Embed(
title=f"Warnings for {member.display_name}",
color=discord.Color.yellow(),
description=f"Total: {len(warnings)} warning(s)"
)
for w in warnings[:10]: # Show last 10
embed.add_field(
name=f"#{w['id']} - {w['created_at'][:10]}",
value=w['reason'],
inline=False
)
await interaction.response.send_message(embed=embed)
@bot.tree.command(name="tag", description="Retrieve a saved tag")
@app_commands.describe(name="Tag name")
async def tag_cmd(interaction: discord.Interaction, name: str):
tag = await get_tag(name.lower(), interaction.guild.id)
if tag:
await interaction.response.send_message(tag["content"])
else:
await interaction.response.send_message(f"Tag {name} not found.", ephemeral=True)
@bot.tree.command(name="tagcreate", description="Create a new tag")
@app_commands.describe(name="Tag name", content="Tag content")
async def tag_create(interaction: discord.Interaction, name: str, content: str):
await save_tag(name.lower(), interaction.guild.id, content, interaction.user.id)
await interaction.response.send_message(f"Tag {name} created.", ephemeral=True)
Organizing with Cogs
As your bot grows, put related commands in separate files called Cogs:
# cogs/utility.py
import discord
from discord.ext import commands
from discord import app_commands
class Utility(commands.Cog):
def __init__(self, bot: commands.Bot):
self.bot = bot
@app_commands.command(name="avatar", description="Get a user's avatar")
@app_commands.describe(member="The user whose avatar to show")
async def avatar(self, interaction: discord.Interaction, member: discord.Member = None):
member = member or interaction.user
embed = discord.Embed(
title=f"{member.display_name}'s Avatar",
color=discord.Color.blue()
)
embed.set_image(url=member.display_avatar.url)
await interaction.response.send_message(embed=embed)
@app_commands.command(name="poll", description="Create a simple poll")
@app_commands.describe(question="The poll question")
async def poll(self, interaction: discord.Interaction, question: str):
embed = discord.Embed(
title="Poll",
description=question,
color=discord.Color.purple()
)
embed.set_footer(text=f"Asked by {interaction.user.display_name}")
await interaction.response.send_message(embed=embed)
msg = await interaction.original_response()
await msg.add_reaction("\u2705") # check mark
await msg.add_reaction("\u274c") # X mark
async def setup(bot: commands.Bot):
await bot.add_cog(Utility(bot))
Load cogs in your main bot file:
import asyncio
async def load_extensions():
for cog in ["cogs.utility", "cogs.moderation"]:
await bot.load_extension(cog)
print(f"Loaded {cog}")
@bot.event
async def on_ready():
await init_db()
await load_extensions()
synced = await bot.tree.sync()
print(f"Ready! Synced {len(synced)} commands.")
Deploying to a VPS
Your bot needs to run 24/7. A small VPS (DigitalOcean, Hetzner, Linode -- $4-6/month) is the standard approach.
SSH into your server and set up:
# Install Python
sudo apt update
sudo apt install python3 python3-pip python3-venv
# Clone your bot
git clone https://github.com/you/discord-bot.git
cd discord-bot
# Set up virtual environment
python3 -m venv venv
source venv/bin/activate
pip install -r requirements.txt
Create a .env file with your token, then set up a systemd service for auto-restart:
# /etc/systemd/system/discord-bot.service
[Unit]
Description=Discord Bot
After=network.target
[Service]
Type=simple
User=ubuntu
WorkingDirectory=/home/ubuntu/discord-bot
Environment=PATH=/home/ubuntu/discord-bot/venv/bin
ExecStart=/home/ubuntu/discord-bot/venv/bin/python bot.py
Restart=always
RestartSec=10
[Install]
WantedBy=multi-user.target
Enable and start:
sudo systemctl enable discord-bot
sudo systemctl start discord-bot
sudo systemctl status discord-bot
# View logs
sudo journalctl -u discord-bot -f
Your bot is now running in the background, auto-restarts on crashes, and survives server reboots.
Common Mistakes
Not syncing slash commands. Slash commands require explicit syncing with Discord. Callbot.tree.sync() in on_ready. And remember, syncing has a rate limit -- don't sync on every restart in production. Sync once during development, and use guild-specific syncing for testing:
await bot.tree.sync(guild=discord.Object(id=YOUR_GUILD_ID)) # Instant, guild-only
Blocking the event loop. discord.py is async. If you run a CPU-intensive or blocking operation (file I/O, database queries without async), it freezes the entire bot. Use asyncio.to_thread() for blocking operations or async libraries like aiosqlite.
Storing the token in code. Use environment variables or .env files. Never commit your token to git. If you accidentally do, regenerate it immediately in the Developer Portal.
Not handling rate limits. Discord has strict rate limits. discord.py handles them automatically, but if you're sending messages in a loop, add delays or use asyncio.sleep().
Ignoring intents. If your bot doesn't receive events (like on_message not firing), you probably forgot to enable the required intents both in code and in the Developer Portal.
What's Next
Your bot is running, responding to commands, and persisting data. Here's where you can take it:
- Reaction roles -- Let users self-assign roles by reacting to a message
- Music playback -- Use
wavelinkorlavalinkfor audio streaming - Web dashboard -- Add a Flask/FastAPI dashboard that controls bot settings
- Scheduled tasks -- Use
discord.ext.tasksfor recurring jobs (daily reminders, stats) - Interactions -- Buttons, select menus, and modals for rich UI
- Sharding -- Scale to 2,500+ servers with automatic sharding
For more Python tutorials and project ideas, check out CodeUp.