Skip to main content

Python Discord Bot Commands: How to Build Them

A Discord bot command is a text trigger (e.g., !hello or !roll 20) that executes a Python function. Commands are defined as async functions decorated with @bot.command() and respond with messages, embeds, or interactions. The discord.py framework parses the command prefix and arguments, calls your handler, and sends the response to the channel. Building commands teaches you function binding, argument parsing, and request-response patterns essential to any interactive bot.

Creating Your First Text Command

A text command is a function decorated with @bot.command() that responds to text starting with your bot's prefix (usually !). Here's a minimal example:

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

load_dotenv()
bot = commands.Bot(command_prefix='!', intents=discord.Intents.default())

@bot.command(name='ping')
async def ping(ctx):
"""Respond with 'Pong!' and latency."""
latency = bot.latency * 1000 # Convert to milliseconds
await ctx.send(f'Pong! Latency: {latency:.2f}ms')

bot.run(os.getenv('DISCORD_TOKEN'))

When a user types !ping in a channel, the ping() function is called with a Context object (ctx). The context holds the message, author, guild, and channel. await ctx.send() sends a message back to the channel where the command was issued. Run this and type !ping in your server; the bot responds with measured latency.

Passing Arguments to Commands

Commands often need input. Define function parameters after ctx to capture arguments:

@bot.command(name='roll')
async def roll(ctx, sides: int = 20):
"""Roll a virtual die with N sides (default 20)."""
import random
result = random.randint(1, sides)
await ctx.send(f'{ctx.author.mention} rolled a d{sides}: **{result}**')

@bot.command(name='greet')
async def greet(ctx, *, name: str):
"""Greet a person by name. Use asterisk to capture multiple words."""
await ctx.send(f'Hello, {name}! Welcome to the server.')

In the roll command, the sides: int parameter converts the first argument to an integer; if the user types !roll 100, sides is 100. The = 20 sets a default; if the user types just !roll, sides defaults to 20.

In the greet command, the *, name: str syntax captures all remaining text after the prefix as a single string. Without the *, discord.py splits arguments by spaces, so !greet Alice Smith would only capture Alice. With *, it captures Alice Smith as one parameter.

Adding Help Text and Descriptions

Discord displays command descriptions in the help menu and in slash command interfaces. Pass description to @bot.command():

@bot.command(
name='weather',
description='Get the current temperature for a city.',
brief='Check weather'
)
async def weather(ctx, city: str):
"""Fetch mock weather for a city. Replace with a real API call in production."""
# Simulate a weather lookup
temp = 72 # Placeholder
await ctx.send(f'Weather in {city}: {temp}°F')

The brief is a one-line summary shown in command lists; the docstring (triple-quoted text) is the detailed help. Users can type !help weather to see the description.

Organizing Commands with Command Groups

For related commands, use @commands.group() to create a parent command with subcommands:

@commands.group(name='user')
async def user_group(ctx):
"""Commands for user management."""
if ctx.invoked_subcommand is None:
await ctx.send('Use `!user profile`, `!user stats`, or `!user settings`.')

@user_group.command(name='profile')
async def user_profile(ctx, target: discord.Member = None):
"""Show a user's profile."""
target = target or ctx.author
await ctx.send(f'Profile of {target.mention}: {target.name}#{target.discriminator}')

@user_group.command(name='stats')
async def user_stats(ctx, target: discord.Member = None):
"""Show a user's stats (placeholder)."""
target = target or ctx.author
await ctx.send(f'Stats for {target.mention}: Level 42, XP 5000')

Users now invoke these as !user profile, !user stats, and !user settings. The parent handler checks ctx.invoked_subcommand; if the user typed !user without a subcommand, it prints the help.

Error Handling in Commands

If a user provides invalid arguments, discord.py raises an error. Handle it with a local error handler:

@bot.command(name='add')
async def add(ctx, a: int, b: int):
"""Add two numbers."""
result = a + b
await ctx.send(f'{a} + {b} = {result}')

@add.error
async def add_error(ctx, error):
"""Handle errors for the add command."""
if isinstance(error, commands.MissingRequiredArgument):
await ctx.send('Usage: `!add <number1> <number2>`')
elif isinstance(error, commands.BadArgument):
await ctx.send('Both arguments must be integers.')
else:
await ctx.send(f'Error: {str(error)}')

When a user types !add abc 5, discord.py tries to parse abc as an int, fails, and calls the error handler. The handler checks the error type and sends a helpful message. Without this, the user sees a generic error.

Checking Permissions and Roles

Only certain users should run certain commands. Use decorators to enforce permissions:

@bot.command(name='ban')
@commands.has_permissions(ban_members=True)
@commands.bot_has_permissions(ban_members=True)
async def ban_user(ctx, member: discord.Member, *, reason='No reason provided'):
"""Ban a member (mod only)."""
await member.ban(reason=reason)
await ctx.send(f'{member.mention} has been banned for: {reason}')

@ban_user.error
async def ban_error(ctx, error):
if isinstance(error, commands.MissingPermissions):
await ctx.send('You need the Ban Members permission.')
elif isinstance(error, commands.BotMissingPermissions):
await ctx.send('I don\'t have permission to ban members.')

@commands.has_permissions(ban_members=True) checks that the user invoking the command has the ban_members permission. @commands.bot_has_permissions(ban_members=True) checks that your bot also has that permission in the guild. If either check fails, the error handler is called with a MissingPermissions exception.

Best Practices: Validation and Rate-Limiting

For public commands, validate input and consider rate-limiting. The commands.cooldown() decorator prevents spam:

from discord.ext.commands import cooldown, BucketType

@bot.command(name='lottery')
@cooldown(1, 60, BucketType.user) # 1 use per 60 seconds, per user
async def lottery(ctx):
"""Enter a daily lottery (once per minute per user)."""
import random
if random.random() < 0.1: # 10% chance
await ctx.send(f'{ctx.author.mention} won 1000 coins!')
else:
await ctx.send(f'{ctx.author.mention} did not win this time.')

@lottery.error
async def lottery_error(ctx, error):
if isinstance(error, commands.CommandOnCooldown):
await ctx.send(f'Try again in {error.retry_after:.1f}s.')

The BucketType.user means each user has their own cooldown; other buckets are guild, channel, and default (global). This prevents one user from spamming a command 100 times in a second.

FeatureText CommandSlash Command
Invocation!command arg1 arg2/command arg1: value arg2: value (built-in menus)
VisibilityUsers must know the prefixDiscoverable in slash command menu
Mobile-friendlyHarder (requires typing)Native mobile support
Learning curveLower for beginnersSlightly steeper (covered in article 4)

Key Takeaways

  • Define commands as async functions decorated with @bot.command(), with a ctx parameter as the first argument.
  • Use function parameters to capture arguments; type hints like int, str, or discord.Member convert and validate input automatically.
  • Use the * syntax to capture multi-word arguments: async def cmd(ctx, *, text: str).
  • Organize related commands into groups with @commands.group() and subcommands.
  • Add error handlers with @command_name.error to catch parsing failures and missing arguments gracefully.
  • Enforce permissions with @commands.has_permissions() and @commands.bot_has_permissions().
  • Apply @cooldown() decorators to prevent spam and abuse.

Frequently Asked Questions

Can I make a command respond with an embed instead of plain text?

Yes, create a discord.Embed object and pass it to ctx.send(). See article 5 for rich embed examples that fetch and format external data.

How do I get the user who invoked the command?

The ctx.author attribute is the user who triggered the command. Use ctx.author.mention to ping them, ctx.author.name for their username, and ctx.author.id for their Discord ID.

What happens if I define two commands with the same name?

The second definition overwrites the first. Keep command names unique. If you need aliases, use the aliases parameter: @bot.command(name='ping', aliases=['p', 'latency']).

Can I have a command that mentions everyone or notifies a role?

Yes, use @everyone or @role_name in the message text, but be careful—abusing mentions can spam users. Prefer sending a message to a specific channel and only notifying when necessary.

How do I delete a user's message after running a command?

Call await ctx.message.delete() after processing. The bot needs the Manage Messages permission in that channel. Example: await ctx.message.delete() then await ctx.send('Command processed.').

Further Reading