Discord Slash Commands in Python: Modern Guide
Slash commands are Discord's modern command interface: /command option1:value option2:value with built-in autocomplete, validation, and type-safe menus visible in the Discord client. Unlike prefix commands, slash commands are self-documenting (users see available commands and their arguments in the command picker) and mobile-friendly. In discord.py 2.0 and later, slash commands are defined using @app_commands.command() with type hints and validators, improving clarity and reducing errors.
Your First Slash Command
Slash commands require the @app_commands.command() decorator from discord.py's app_commands extension. Here's a minimal example:
import discord
from discord.ext import commands
from discord import app_commands
import os
from dotenv import load_dotenv
load_dotenv()
bot = commands.Bot(command_prefix='!', intents=discord.Intents.default())
@bot.event
async def on_ready():
"""Sync app commands when the bot is ready."""
try:
synced = await bot.tree.sync()
print(f'Synced {len(synced)} command(s)')
except Exception as e:
print(f'Failed to sync commands: {e}')
@app_commands.command(name='greet', description='Greet a person')
async def greet(interaction: discord.Interaction, name: str):
"""Respond with a greeting."""
await interaction.response.send_message(f'Hello, {name}!')
bot.run(os.getenv('DISCORD_TOKEN'))
The key differences from prefix commands: the decorator is @app_commands.command() instead of @bot.command(), the parameter is interaction: discord.Interaction instead of ctx, and you call await interaction.response.send_message() instead of await ctx.send(). The parameter name: str is automatically converted to an option in Discord's UI. Call await bot.tree.sync() in on_ready to register slash commands with Discord; this happens once per bot restart.
Command Options with Types and Choices
Slash command options are function parameters with type hints. Discord validates input before sending it to your bot:
from enum import Enum
class Difficulty(str, Enum):
easy = "easy"
medium = "medium"
hard = "hard"
@app_commands.command(name='quiz', description='Start a quiz game')
async def quiz(
interaction: discord.Interaction,
topic: str,
difficulty: Difficulty = Difficulty.medium,
num_questions: int = 5
):
"""Start a quiz with a topic and difficulty level."""
await interaction.response.send_message(
f'Starting {difficulty} {topic} quiz with {num_questions} questions.'
)
# Alternative: choices without enum
@app_commands.command(name='choose')
@app_commands.describe(color='Pick your favorite color')
@app_commands.choices(color=[
app_commands.Choice(name='Red', value='red'),
app_commands.Choice(name='Blue', value='blue'),
app_commands.Choice(name='Green', value='green'),
])
async def choose(interaction: discord.Interaction, color: str):
"""Choose a color from a dropdown menu."""
await interaction.response.send_message(f'You chose: {color}')
Type hints like int, str, bool, discord.Member, and discord.Role are converted to appropriate Discord option types. Enums create dropdown menus in the UI. The @app_commands.choices() decorator defines a fixed list of options; users see a dropdown instead of typing freely. Default values (e.g., Difficulty.medium) make options optional.
Autocomplete: Dynamic Suggestions
Autocomplete fetches suggestions in real-time as the user types. This is powerful for options with many possible values:
async def available_games(
interaction: discord.Interaction,
current: str,
) -> list[app_commands.Choice[str]]:
"""Autocomplete callback for game names."""
games = ['Minecraft', 'Valorant', 'League of Legends', 'Counter-Strike 2']
# Filter games that match the current input
return [
app_commands.Choice(name=game, value=game)
for game in games if game.lower().startswith(current.lower())
]
@app_commands.command(name='startgame')
@app_commands.autocomplete('game_name', available_games)
async def startgame(interaction: discord.Interaction, game_name: str):
"""Start a game session."""
await interaction.response.send_message(f'Starting {game_name}...')
The autocomplete callback receives the current user input and returns a list of app_commands.Choice objects. As the user types, Discord calls this function and shows matching suggestions. This is far better UX than static choices for large option lists.
Command Groups and Subcommands
Organize related slash commands into groups:
# Create a command group
guild_group = app_commands.Group(
name='guild',
description='Guild management commands'
)
@guild_group.command(name='info', description='Show guild information')
async def guild_info(interaction: discord.Interaction):
"""Display information about the guild."""
guild = interaction.guild
embed = discord.Embed(
title=guild.name,
description=f'Members: {guild.member_count}',
color=discord.Color.blue()
)
await interaction.response.send_message(embed=embed)
@guild_group.command(name='settings', description='View guild settings')
async def guild_settings(interaction: discord.Interaction):
"""Show guild configuration."""
await interaction.response.send_message('Guild settings page (placeholder).')
# Register the group with the bot
bot.tree.add_command(guild_group)
Users invoke these as /guild info and /guild settings. Groups make the command menu cleaner and logically organize related functionality.
Permission Checks and Moderation
Use decorators to enforce permissions for slash commands:
@app_commands.command(name='ban')
@app_commands.checks.has_permissions(ban_members=True)
@app_commands.checks.bot_has_permissions(ban_members=True)
@app_commands.describe(
user='User to ban',
reason='Reason for the ban'
)
async def ban_user(
interaction: discord.Interaction,
user: discord.User,
reason: str = 'No reason provided'
):
"""Ban a user from the server."""
try:
await interaction.guild.ban(user, reason=reason)
await interaction.response.send_message(
f'{user} has been banned: {reason}'
)
except discord.Forbidden:
await interaction.response.send_message(
'I cannot ban this user.',
ephemeral=True
)
@ban_user.error
async def ban_error(interaction: discord.Interaction, error: app_commands.AppCommandError):
"""Handle errors for the ban command."""
if isinstance(error, app_commands.MissingPermissions):
await interaction.response.send_message(
'You need the Ban Members permission.',
ephemeral=True
)
elif isinstance(error, app_commands.BotMissingPermissions):
await interaction.response.send_message(
'I don\'t have the Ban Members permission.',
ephemeral=True
)
@app_commands.checks.has_permissions() validates the user's permissions. The ephemeral=True parameter makes the response visible only to the user who invoked the command; others don't see it. This is ideal for error messages or sensitive information.
Deferred Responses and Editing
If your command takes time to process (e.g., querying an API), defer the response to show a loading state:
@app_commands.command(name='slowapi')
async def slowapi(interaction: discord.Interaction):
"""Fetch data from a slow API."""
# Defer the response (shows "thinking" in Discord)
await interaction.response.defer()
# Do slow work
import asyncio
await asyncio.sleep(3) # Simulate slow API call
# Follow up with the result
await interaction.followup.send('API call completed!')
@app_commands.command(name='edit')
async def edit_message(interaction: discord.Interaction):
"""Send a message and then edit it."""
await interaction.response.send_message('Initial message.')
# Later, edit the response
import asyncio
await asyncio.sleep(2)
await interaction.edit_original_response(content='Edited message!')
await interaction.response.defer() acknowledges the interaction without sending a message yet (shows "Bot is thinking..."). Later, call await interaction.followup.send() to send additional messages. For the initial response, await interaction.edit_original_response() modifies it after sending. This prevents Discord's 3-second timeout for responding.
Comparison: Prefix vs. Slash Commands
| Aspect | Prefix Command | Slash Command |
|---|---|---|
| Invocation | !help command_name | /help command_name (built-in menu) |
| Type validation | Manual parsing | Automatic, enforced by Discord |
| Autocomplete | Must implement custom | Native support |
| Mobile UX | Poor (typing required) | Excellent (tappable menu) |
| Default option | /command shows help | Users see all options in menu |
| Ephemeral responses | Not directly supported | Native ephemeral=True |
| Learning curve | Lower for beginners | Modern standard, slightly steeper |
Key Takeaways
- Use
@app_commands.command()andinteraction: discord.Interactionto define slash commands in discord.py 2.0+. - Type hints on function parameters become command options; Discord enforces types before reaching your bot.
- Call
await bot.tree.sync()inon_readyto register commands; syncing must happen at least once. - Use
@app_commands.choices()for fixed option lists or@app_commands.autocomplete()for dynamic suggestions. - Organize related commands with
app_commands.Group()and subcommands. - Use
ephemeral=Truefor private responses anddefer()+followup.send()for long-running operations. - Apply
@app_commands.checks.has_permissions()to enforce permission checks.
Frequently Asked Questions
How often do I need to sync commands?
Sync once when the bot starts (on_ready) or whenever you add/remove commands. Discord caches synced commands, so subsequent restarts don't require re-syncing unless commands change.
Can I have both prefix and slash commands?
Yes, use both @bot.command() and @app_commands.command() decorators on separate functions. Users invoke them differently, but both can coexist.
What is the difference between deferand ephemeral?
Deferral (defer()) delays sending an initial response (useful for slow operations). Ephemeral (ephemeral=True) hides the response from others; it's visible only to the invoking user. They solve different problems: deferis for latency, ephemeral is for privacy.
Can slash command options be lists or arrays?
Discord's native option types don't support arrays. Use multiple separate options or a single string that the user provides in comma-separated format; parse it in code.
How do I show different subcommand options based on user input?
Discord doesn't support conditional option visibility. Design command structure ahead of time. Use groups and separate subcommands if you need different options for different scenarios.