March 27, 202612 min read

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 python bot automation tutorial
Ad 336x280

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:

  1. Go to the Discord Developer Portal
  2. Click "New Application" and give it a name
  3. Go to the "Bot" tab and click "Add Bot"
  4. Under "Privileged Gateway Intents", enable:
- Message Content Intent (if you want to read message content) - Server Members Intent (if you need member data) - Presence Intent (if you need online/offline status)
  1. Click "Reset Token" and copy your bot token. Store it somewhere safe -- you can't see it again.
Now create an invite URL:
  1. Go to "OAuth2" > "URL Generator"
  2. Select scopes: bot, applications.commands
  3. Select permissions: Send Messages, Manage Messages, Embed Links, Read Message History, Use Slash Commands, and whatever else your bot needs
  4. 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. Call bot.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 wavelink or lavalink for audio streaming
  • Web dashboard -- Add a Flask/FastAPI dashboard that controls bot settings
  • Scheduled tasks -- Use discord.ext.tasks for recurring jobs (daily reminders, stats)
  • Interactions -- Buttons, select menus, and modals for rich UI
  • Sharding -- Scale to 2,500+ servers with automatic sharding
Discord bots are a gateway into async programming, event-driven architecture, and real-time systems. And they're genuinely fun to build -- you get to watch people use your code in real time.

For more Python tutorials and project ideas, check out CodeUp.

Ad 728x90