Intermediate

Discord has over 150 million monthly active users, and bots are what keep servers running smoothly. Whether you want to build a moderation bot that auto-kicks spammers, a music bot that plays audio in voice channels, or a utility bot that fetches data from APIs and posts it in a channel, Python and the discord.py library make it surprisingly approachable. If you have ever wished your Discord server could do something automatically, a bot is the answer.

The discord.py library handles all the heavy lifting — connecting to Discord’s WebSocket gateway, managing authentication, parsing events, and sending messages. You just need Python 3.8 or higher and a free Discord account. You will install discord.py with a single pip install command, and within minutes you will have a bot running in your own server responding to commands.

In this article we will walk through the entire process from start to finish. We will begin with setting up the Discord Developer Portal and creating your bot application. Then we will cover bot events, text commands, modern slash commands, and rich embed messages. Along the way we will explain how Discord’s event-driven architecture works and why it matters. Finally, we will build a complete Server Welcome Bot as a real-life project that greets new members, assigns roles, and logs activity.

Discord Bot in Python: Quick Example

Here is the simplest possible Discord bot that connects to a server and responds when someone types !hello. You can copy this code, replace the token placeholder with your actual bot token (we will show you how to get one in the next section), and have a working bot in under a minute.

# quick_example.py
import discord

intents = discord.Intents.default()
intents.message_content = True  # Required to read message text

client = discord.Client(intents=intents)

@client.event
async def on_ready():
    print(f"Bot is online as {client.user}")

@client.event
async def on_message(message):
    if message.author == client.user:
        return  # Ignore the bot's own messages
    if message.content == "!hello":
        await message.channel.send(f"Hello, {message.author.display_name}!")

client.run("YOUR_BOT_TOKEN_HERE")

Output (in your terminal):

Bot is online as MyBot#1234

When someone types !hello in any channel the bot can see, it replies with a personalized greeting. The on_ready event fires once when the bot successfully connects to Discord, and the on_message event fires every time a message is sent in a channel the bot has access to. The intents object tells Discord what types of events your bot needs — we enable message_content because reading message text requires explicit permission since 2022.

Want to learn how to set up your bot properly, use modern slash commands, and build something more substantial? Below we cover everything step by step.

Setting Up the Discord Developer Portal

Before you write any code, you need to create a bot application on Discord’s Developer Portal. This gives you a bot token (like a password for your bot) and lets you configure what permissions the bot needs. Here is the step-by-step process.

Go to https://discord.com/developers/applications and log in with your Discord account. Click “New Application” in the top right, give your application a name (this will be your bot’s display name), and click “Create.” On the left sidebar, click “Bot” to open the bot settings. Under the “Privileged Gateway Intents” section, enable Message Content Intent — this is required for your bot to read the text content of messages. You may also want to enable Server Members Intent if your bot needs to track when members join or leave.

To get your bot token, click “Reset Token” on the Bot page. Copy the token immediately — Discord only shows it once. Store it somewhere safe and never share it publicly or commit it to a Git repository. Anyone with your token can control your bot. If you accidentally expose it, go back to the Developer Portal and reset it immediately.

To invite your bot to a server, go to the “OAuth2” section in the left sidebar, then “URL Generator.” Under “Scopes” check bot and applications.commands (for slash commands). Under “Bot Permissions” select the permissions your bot needs — for this tutorial, check Send Messages, Read Message History, Manage Roles, and Embed Links. Copy the generated URL at the bottom and open it in your browser to invite the bot to your test server.

Installing discord.py

Install the library using pip. We recommend installing inside a virtual environment to keep your project dependencies isolated.

# install_discord.py
# Run these commands in your terminal (not in a Python file):
# pip install discord.py
#
# To verify the installation:
import discord
print(f"discord.py version: {discord.__version__}")

Output:

discord.py version: 2.5.2

The discord.py library installs with all its dependencies, including aiohttp for async HTTP requests and websockets for the real-time connection to Discord’s gateway. The version number may differ depending on when you install it, but any version 2.x will work with the code in this tutorial. If you need voice channel support, install discord.py[voice] instead, which includes the PyNaCl library for audio encoding.

Pyro Pete excited with chat bubbles and lightning bolts celebrating his Discord bot going online
Your bot just went online. Time to test it by talking to yourself in an empty Discord server like a normal person.

Understanding Bot Events

Discord bots are event-driven. Instead of running code in a loop, your bot sits idle and waits for Discord to send it events — a message was sent, a member joined, a reaction was added, and so on. You register handler functions for the events you care about using the @client.event decorator. Discord’s gateway sends events over a WebSocket connection, and discord.py automatically parses them into Python objects for you.

Here are the most commonly used events and when they fire:

EventWhen It FiresCommon Use
on_readyBot connects to DiscordPrint status message, initialize data
on_messageAny message is sentText commands, auto-moderation
on_member_joinA user joins the serverWelcome messages, auto-role assignment
on_member_removeA user leaves/is kickedGoodbye messages, logging
on_reaction_addSomeone reacts to a messageReaction roles, polls
on_message_deleteA message is deletedAudit logging, anti-spam

Let us see a bot that responds to multiple events. This example logs when the bot starts, when messages are sent, and when new members join the server.

# event_demo.py
import discord
from datetime import datetime

intents = discord.Intents.default()
intents.message_content = True
intents.members = True  # Required for member join/leave events

client = discord.Client(intents=intents)

@client.event
async def on_ready():
    print(f"[{datetime.now():%H:%M:%S}] {client.user} is online!")
    print(f"Connected to {len(client.guilds)} server(s)")

@client.event
async def on_message(message):
    if message.author.bot:
        return  # Ignore all bot messages
    print(f"[{datetime.now():%H:%M:%S}] {message.author}: {message.content}")

@client.event
async def on_member_join(member):
    print(f"[{datetime.now():%H:%M:%S}] {member.display_name} joined {member.guild.name}")

client.run("YOUR_BOT_TOKEN_HERE")

Output (in your terminal):

[14:30:00] MyBot#1234 is online!
Connected to 1 server(s)
[14:30:15] Alice: Hello everyone!
[14:30:22] Bob: Hey Alice!
[14:31:05] Charlie joined My Test Server

Notice that we check message.author.bot instead of comparing to client.user. This is a best practice because it prevents your bot from responding to messages from any bot, not just itself. Without this check, two bots could get into an infinite loop responding to each other. The intents.members = True line is required for the on_member_join event to work — Discord requires you to explicitly opt into member-related events for privacy reasons.

Creating Text Commands

Text commands (also called prefix commands) are the classic way to interact with a Discord bot — users type a prefix like ! followed by a command name. While Discord now recommends slash commands for new bots, text commands are still widely used and are simpler to understand for beginners. The discord.py library provides a commands.Bot class that makes text commands easy to build.

# text_commands.py
import discord
from discord.ext import commands

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

# Use commands.Bot instead of discord.Client for command support
bot = commands.Bot(command_prefix="!", intents=intents)

@bot.event
async def on_ready():
    print(f"{bot.user} is online with prefix '!'")

@bot.command(name="ping")
async def ping_command(ctx):
    """Check if the bot is responsive."""
    latency = round(bot.latency * 1000)  # Convert to milliseconds
    await ctx.send(f"Pong! Latency: {latency}ms")

@bot.command(name="say")
async def say_command(ctx, *, text: str):
    """Make the bot repeat your message."""
    await ctx.send(text)

@bot.command(name="userinfo")
async def userinfo_command(ctx, member: discord.Member = None):
    """Show information about a user."""
    member = member or ctx.author  # Default to the command caller
    joined = member.joined_at.strftime("%B %d, %Y") if member.joined_at else "Unknown"
    roles = ", ".join(role.name for role in member.roles[1:]) or "No roles"
    await ctx.send(
        f"**{member.display_name}**\n"
        f"Joined: {joined}\n"
        f"Roles: {roles}\n"
        f"Account created: {member.created_at.strftime('%B %d, %Y')}"
    )

bot.run("YOUR_BOT_TOKEN_HERE")

Output (in Discord chat):

User: !ping
Bot:  Pong! Latency: 45ms

User: !say Hello from the bot!
Bot:  Hello from the bot!

User: !userinfo
Bot:  **Alice**
      Joined: January 15, 2025
      Roles: Moderator, Developer
      Account created: March 10, 2020

The commands.Bot class extends discord.Client with a command framework. Instead of manually parsing message content in on_message, you define commands with the @bot.command() decorator. The ctx parameter (short for context) gives you access to the message, the channel, the author, and the server — everything you need to respond. The * in *, text: str tells discord.py to capture all remaining text as a single string instead of splitting on spaces. The member: discord.Member type hint enables automatic user lookup — users can mention someone or type their name and discord.py will find the matching member.

Debug Dee examining a glowing crystal orb with swirling patterns and question marks for slash commands
Slash commands: because apparently typing / is cooler than typing ! now.

Creating Slash Commands

Slash commands are Discord’s modern command system. When a user types /, Discord shows a menu of available commands with descriptions and parameter hints — no guessing what commands exist or what arguments they need. Discord strongly recommends slash commands for all new bots because they provide a better user experience and integrate with Discord’s permission system.

# slash_commands.py
import discord
from discord import app_commands

intents = discord.Intents.default()
client = discord.Client(intents=intents)
tree = app_commands.CommandTree(client)

@tree.command(name="ping", description="Check bot latency")
async def ping_slash(interaction: discord.Interaction):
    latency = round(client.latency * 1000)
    await interaction.response.send_message(f"Pong! Latency: {latency}ms")

@tree.command(name="roll", description="Roll a dice with a given number of sides")
@app_commands.describe(sides="Number of sides on the dice (default: 6)")
async def roll_slash(interaction: discord.Interaction, sides: int = 6):
    import random
    result = random.randint(1, sides)
    await interaction.response.send_message(f"You rolled a **{result}** (d{sides})")

@tree.command(name="poll", description="Create a simple yes/no poll")
@app_commands.describe(question="The question to ask")
async def poll_slash(interaction: discord.Interaction, question: str):
    message = await interaction.response.send_message(f"**Poll:** {question}")
    # Fetch the message object to add reactions
    poll_message = await interaction.original_response()
    await poll_message.add_reaction("✅")
    await poll_message.add_reaction("❌")

@client.event
async def on_ready():
    await tree.sync()  # Register commands with Discord
    print(f"{client.user} is online with slash commands!")

client.run("YOUR_BOT_TOKEN_HERE")

Output (in Discord chat):

User types: /ping
Bot:  Pong! Latency: 38ms

User types: /roll sides:20
Bot:  You rolled a **14** (d20)

User types: /poll question:Should we do movie night?
Bot:  **Poll:** Should we do movie night?
      ✅ ❌ (reactions added automatically)

Slash commands use a different API pattern than text commands. Instead of ctx, you receive an interaction object, and you must respond with interaction.response.send_message() within 3 seconds — otherwise Discord shows a “This interaction failed” error to the user. The app_commands.CommandTree manages your slash commands, and tree.sync() in on_ready registers them with Discord. Note that syncing can take up to an hour for global commands, but you can speed this up during development by syncing to a specific server using tree.sync(guild=discord.Object(id=YOUR_SERVER_ID)).

The @app_commands.describe() decorator adds parameter descriptions that Discord shows in the command menu. Type hints like sides: int tell Discord to validate the input — if a user types text instead of a number, Discord will show an error before the command even runs.

Creating Embed Messages

Plain text messages work fine, but embed messages look professional. Embeds support titles, descriptions, fields, colors, thumbnails, and footers — all rendered in a rich card format. They are perfect for displaying structured information like user profiles, search results, or status dashboards.

# embed_demo.py
import discord
from discord import app_commands
from datetime import datetime

intents = discord.Intents.default()
client = discord.Client(intents=intents)
tree = app_commands.CommandTree(client)

@tree.command(name="serverinfo", description="Show information about this server")
async def serverinfo_slash(interaction: discord.Interaction):
    guild = interaction.guild
    embed = discord.Embed(
        title=guild.name,
        description=f"Server information for **{guild.name}**",
        color=discord.Color.blue(),
        timestamp=datetime.now()
    )
    embed.add_field(name="Owner", value=guild.owner.display_name if guild.owner else "Unknown", inline=True)
    embed.add_field(name="Members", value=guild.member_count, inline=True)
    embed.add_field(name="Channels", value=len(guild.channels), inline=True)
    embed.add_field(name="Roles", value=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=f"Level {guild.premium_tier}", inline=True)

    if guild.icon:
        embed.set_thumbnail(url=guild.icon.url)
    embed.set_footer(text=f"Server ID: {guild.id}")

    await interaction.response.send_message(embed=embed)

@client.event
async def on_ready():
    await tree.sync()
    print(f"{client.user} is online!")

client.run("YOUR_BOT_TOKEN_HERE")

Output (in Discord chat — rendered as a rich card):

┌─────────────────────────────────┐
│ My Test Server                   │
│ Server information for           │
│ **My Test Server**               │
│                                  │
│ Owner: Alice    Members: 42      │
│ Channels: 15   Roles: 8         │
│ Created: Jan 15, 2024            │
│ Boost Level: Level 2             │
│                                  │
│ Server ID: 123456789012345678    │
└─────────────────────────────────┘

The discord.Embed constructor accepts a title, description, color, and timestamp. The add_field() method adds labeled data — setting inline=True places fields side by side (up to 3 per row), while inline=False gives each field its own row. The set_thumbnail() method adds a small image in the top-right corner, and set_footer() adds gray text at the bottom. Embeds can also include set_image() for a large image and set_author() for a clickable author name with an icon. The color parameter accepts hex values, RGB tuples, or convenience methods like discord.Color.blue(), discord.Color.red(), and discord.Color.green().

Sudo Sam standing proudly next to a colorful robot he built representing a Discord bot
Cogs, error handling, logging, and a clean architecture. This bot scales. Yours should too.

Real-Life Example: Server Welcome Bot

Let us build a complete, practical bot that you can actually deploy to your Discord server. This Server Welcome Bot greets new members with a personalized embed message, automatically assigns them a default role, logs all join and leave events to a designated channel, and provides a /stats slash command that shows server statistics.

# welcome_bot.py
import discord
from discord import app_commands
from datetime import datetime

intents = discord.Intents.default()
intents.message_content = True
intents.members = True  # Required for join/leave events

client = discord.Client(intents=intents)
tree = app_commands.CommandTree(client)

# Configuration — update these for your server
WELCOME_CHANNEL_NAME = "welcome"
LOG_CHANNEL_NAME = "bot-logs"
DEFAULT_ROLE_NAME = "Member"

join_count = 0  # Track joins during this session

def find_channel(guild, name):
    """Find a text channel by name, returns None if not found."""
    return discord.utils.get(guild.text_channels, name=name)

@client.event
async def on_ready():
    await tree.sync()
    print(f"{client.user} is online!")
    print(f"Monitoring {len(client.guilds)} server(s)")

@client.event
async def on_member_join(member):
    global join_count
    join_count += 1

    # Send welcome embed
    welcome_ch = find_channel(member.guild, WELCOME_CHANNEL_NAME)
    if welcome_ch:
        embed = discord.Embed(
            title=f"Welcome, {member.display_name}!",
            description=f"{member.mention} just joined **{member.guild.name}**! "
                        f"You are member #{member.guild.member_count}.",
            color=discord.Color.green(),
            timestamp=datetime.now()
        )
        if member.avatar:
            embed.set_thumbnail(url=member.avatar.url)
        embed.set_footer(text="Enjoy your stay!")
        await welcome_ch.send(embed=embed)

    # Assign default role
    role = discord.utils.get(member.guild.roles, name=DEFAULT_ROLE_NAME)
    if role:
        try:
            await member.add_roles(role)
        except discord.Forbidden:
            print(f"Missing permission to assign {role.name}")

    # Log the event
    log_ch = find_channel(member.guild, LOG_CHANNEL_NAME)
    if log_ch:
        await log_ch.send(f"[JOIN] {member.display_name} ({member.id}) joined at {datetime.now():%H:%M:%S}")

@client.event
async def on_member_remove(member):
    log_ch = find_channel(member.guild, LOG_CHANNEL_NAME)
    if log_ch:
        await log_ch.send(f"[LEAVE] {member.display_name} ({member.id}) left at {datetime.now():%H:%M:%S}")

@tree.command(name="stats", description="Show server statistics")
async def stats_command(interaction: discord.Interaction):
    guild = interaction.guild
    embed = discord.Embed(title="Server Stats", color=discord.Color.blue())
    embed.add_field(name="Total Members", value=guild.member_count, inline=True)
    embed.add_field(name="Joins This Session", value=join_count, inline=True)
    embed.add_field(name="Channels", value=len(guild.channels), inline=True)
    embed.add_field(name="Roles", value=len(guild.roles), inline=True)
    await interaction.response.send_message(embed=embed)

client.run("YOUR_BOT_TOKEN_HERE")

Output (in Discord — welcome channel):

┌─────────────────────────────────┐
│ Welcome, Charlie!                │
│ @Charlie just joined             │
│ **My Server**! You are member    │
│ #43.                             │
│                                  │
│ Enjoy your stay!                 │
└─────────────────────────────────┘

Output (in Discord — bot-logs channel):

[JOIN] Charlie (987654321098765432) joined at 14:30:00

Output (in Discord — /stats command):

┌─────────────────────────────────┐
│ Server Stats                     │
│ Total Members: 43                │
│ Joins This Session: 1            │
│ Channels: 15    Roles: 8         │
└─────────────────────────────────┘

This bot uses the discord.utils.get() helper function to find channels and roles by name, which is cleaner than looping through lists manually. The try/except discord.Forbidden block around role assignment handles the case where the bot does not have permission to manage roles — without it, the entire on_member_join handler would crash silently. To deploy this bot to your server, create channels named “welcome” and “bot-logs” and a role named “Member,” then update the configuration constants at the top. You can extend this project by adding a /setwelcome command that lets admins change the welcome channel, a database to persist join counts between restarts, or a reaction-role system where members pick their own roles by clicking emoji reactions.

Frequently Asked Questions

How do I keep my bot token secure?

Never hardcode your token directly in your Python file, especially if you push code to GitHub. Instead, store it in an environment variable and read it with os.environ["DISCORD_TOKEN"], or use a .env file with the python-dotenv library. Add .env to your .gitignore file so it never gets committed. If your token is ever exposed, go to the Discord Developer Portal immediately and click “Reset Token” to invalidate the old one.

Should I use slash commands or text commands?

Use slash commands for new bots. Discord officially recommends them, and they provide a better user experience because Discord shows a command menu with descriptions and type validation. Text commands are still supported in discord.py 2.x, but they require the Message Content Intent, which Discord may restrict further in the future. If you are maintaining an older bot that already uses text commands, you can support both by using commands.Bot and adding slash commands alongside existing prefix commands.

Why are my slash commands not showing up in Discord?

Slash commands need to be synced with Discord using tree.sync(). Global commands can take up to one hour to appear. For instant testing, sync to a specific server with tree.sync(guild=discord.Object(id=SERVER_ID)). Also make sure you invited the bot with the applications.commands scope — without it, the bot cannot register slash commands even if you call sync.

Where can I host my Discord bot?

For small bots, a Raspberry Pi or an old laptop running 24/7 works fine. For production, popular hosting options include Railway, Render, and Oracle Cloud Free Tier (which gives you a free VPS). Avoid Heroku’s free tier for bots because it sleeps after 30 minutes of inactivity, which disconnects your bot. Whatever you choose, make sure the host supports long-running processes — Discord bots need a persistent WebSocket connection, not just HTTP request handling.

How do I handle Discord API rate limits?

The discord.py library handles rate limiting automatically. It tracks the rate limit headers from Discord’s API and pauses requests when you are close to the limit. If you are still hitting rate limits, it usually means your bot is sending too many messages too quickly — add delays between bulk operations using asyncio.sleep(). The typical rate limit is 5 requests per 5 seconds per route, but some endpoints like sending messages have stricter limits.

Can my bot run on multiple servers at the same time?

Yes, a single bot instance handles all servers it is invited to. Discord’s gateway sends events from all servers, and discord.py routes them to your event handlers with the appropriate guild context. The interaction.guild or message.guild object tells you which server the event came from. You do not need separate bot instances per server — one process handles everything.

Conclusion

In this article we walked through building a Discord bot from scratch with Python and discord.py. We covered setting up the Developer Portal, understanding the event-driven architecture, building text commands with commands.Bot, creating modern slash commands with app_commands.CommandTree, and designing rich embed messages. The Server Welcome Bot project ties all of these concepts together into a practical, deployable bot that greets new members, assigns roles, and logs activity.

From here, you can extend the welcome bot with a database backend using sqlite3 or aiosqlite for persistent storage, add a moderation system with kick, ban, and mute commands, or integrate external APIs to build a weather bot, trivia bot, or music bot. The discord.py library supports almost everything the Discord API offers, including voice channels, threads, forums, and scheduled events.

For comprehensive documentation on every feature, visit the official discord.py documentation and the Discord Developer Documentation.