Guide

Slash commands in discord.py

Slash commands are the part of discord.py people get stuck on, mostly because of one step the code does not do for you: telling Discord the commands exist.

8 minute read

This uses discord.py 2.x, where slash commands live in discord.app_commands. If you are on 1.7 or older, upgrade first; slash commands are not in the old versions.

A minimal bot with one slash command

import os
import discord
from discord.ext import commands

class MyBot(commands.Bot):
    def __init__(self):
        super().__init__(command_prefix="!", intents=discord.Intents.default())

    async def setup_hook(self):
        await self.tree.sync()

bot = MyBot()

@bot.tree.command(name="ping", description="Check the bot is alive")
async def ping(interaction: discord.Interaction):
    await interaction.response.send_message("Pong")

bot.run(os.environ["BOT_TOKEN"])

Three things matter here. bot.tree is where slash commands are registered. The description is required. And self.tree.sync() is what uploads the list to Discord.

Slash commands do not need the Message Content intent, so the default intents are enough.

Syncing, properly

Sync in setup_hook, not on_ready. on_ready can fire again whenever the bot reconnects, and syncing on every reconnect burns through a daily limit: Discord allows 200 command creations per day per server. setup_hook runs once per start.

Better still, sync only when commands change. A common pattern is an owner-only prefix command:

@bot.command()
@commands.is_owner()
async def sync(ctx):
    synced = await bot.tree.sync()
    await ctx.send(f"Synced {len(synced)} commands")

That needs the Message Content intent for the !sync itself, or you can mention the bot instead of using a prefix.

Global or one server

tree.sync() with no arguments syncs globally, to every server the bot is in. While developing, syncing to a single test server is quicker to check:

TEST_GUILD = discord.Object(id=123456789012345678)

async def setup_hook(self):
    self.tree.copy_global_to(guild=TEST_GUILD)
    await self.tree.sync(guild=TEST_GUILD)

When you go live, sync globally and, if you synced to a test server before, clear that copy so commands do not show twice there:

self.tree.clear_commands(guild=TEST_GUILD)
await self.tree.sync(guild=TEST_GUILD)

Options

Parameters become options, and type hints decide what Discord shows the user:

@bot.tree.command(description="Roll some dice")
@discord.app_commands.describe(sides="How many sides", count="How many dice")
async def roll(interaction: discord.Interaction, sides: int = 6, count: int = 1):
    import random
    rolls = [random.randint(1, sides) for _ in range(count)]
    await interaction.response.send_message(f"{rolls} = {sum(rolls)}")

discord.Member, discord.TextChannel and discord.Role give the user a picker. A parameter with a default is optional.

For a fixed list, use app_commands.choices or a Literal["red", "blue"] type hint.

The three second rule

Discord waits three seconds for your bot to respond to an interaction. Miss it and the user sees The application did not respond, even if your code finishes a moment later.

If a command does anything slow (an API call, a database query, generating an image), acknowledge it first and send the real answer afterwards:

@bot.tree.command(description="Look something up")
async def lookup(interaction: discord.Interaction, query: str):
    await interaction.response.defer()          # shows "thinking..."
    result = await slow_search(query)
    await interaction.followup.send(result)

After deferring you have 15 minutes to send follow-ups. defer(ephemeral=True) makes the eventual reply visible only to the person who ran the command.

You can only use interaction.response once. A second send_message raises InteractionResponded; use followup.send for anything after the first.

Slash commands in a cog

from discord import app_commands
from discord.ext import commands

class Fun(commands.Cog):
    def __init__(self, bot):
        self.bot = bot

    @app_commands.command(description="Say hello")
    async def hello(self, interaction: discord.Interaction):
        await interaction.response.send_message(f"Hello {interaction.user.mention}")

async def setup(bot):
    await bot.add_cog(Fun(bot))

Load the extension before you sync, or the sync uploads a list without the cog's commands:

async def setup_hook(self):
    await self.load_extension("cogs.fun")
    await self.tree.sync()

Commands not showing up

  • Never synced, or synced before the command was defined. Check the return value of tree.sync(): it is the list of what Discord now has.
  • The bot was invited without applications.commands. Older invite links only had the bot scope. Re-invite with both.
  • Your Discord client has an old list. Press Ctrl+R (Cmd+R on a Mac) to reload it.
  • Synced to a guild, looking in another server. Guild syncs only exist in that guild.
  • Hit the daily limit from syncing on every restart. You get a 429 error on sync. Wait, then move the sync out of the startup path.
  • Two bots, or two copies of one. If a different application with the same command names is in the server, you may be running the other one.

Errors

Errors in slash commands do not go through on_command_error. Handle them on the tree:

@bot.tree.error
async def on_app_command_error(interaction, error):
    if isinstance(error, discord.app_commands.MissingPermissions):
        msg = "You do not have permission to use that."
    else:
        msg = "Something went wrong."
        raise error  # still log it
    if interaction.response.is_done():
        await interaction.followup.send(msg, ephemeral=True)
    else:
        await interaction.response.send_message(msg, ephemeral=True)

If commands register fine but do nothing, see bot online but not responding.