Use the language you already know. In September 2026, discord.js 14.27 and discord.py 2.7 cover the same Discord features: slash commands, Components v2 layouts, polls, modals with checkboxes, and the end-to-end encrypted voice Discord has required since March 1, 2026. Neither can build a bot the other can't, so JavaScript vs Python for a Discord bot comes down to your skills, your other code and how the libraries feel day to day.
If you know neither, the practical differences are these. discord.js ships more often (15 stable releases in the last 12 months against 3), bundles TypeScript types, and shares a language with a web dashboard. discord.py needs less code per command, because a Python function signature declares the command's options, and it has task loops and prefix-plus-slash "hybrid" commands built in.
A disclosure: I build client bots in discord.js, and every case study on my work page runs on discord.js 14. I also fix and upgrade discord.py bots. Everything below is checked against the libraries' own releases, repositories and docs on September 27, 2026, and both example bots were tested the same day.
JavaScript vs Python for Discord bots at a glance
| discord.js | discord.py | |
|---|---|---|
| Language | JavaScript or TypeScript on Node.js | Python |
| Latest stable release | 14.27.0, July 15, 2026 | 2.7.1, March 3, 2026 |
| Stable releases in the last 12 months | 15 | 3 |
| Runtime it requires | Node.js 18 or newer (use 22 or 24) | Python 3.8 or newer |
| License | Apache-2.0 | MIT |
| GitHub stars (September 2026) | about 26,800 | about 16,200 |
| How a command's options are defined | A builder chain, separate from the handler | The handler's parameters and type hints |
| How commands are registered | Your own deploy script, or commands.set() |
tree.sync() |
| Type checking | Bundled TypeScript definitions | Inline type hints, checked with Pyright or mypy |
| Extras in the box | Builders, Collection, a sharding manager |
Prefix and hybrid commands, task loops, cogs, UI view classes, an auto-sharded client |
| Voice | Separate @discordjs/voice package |
discord.py[voice] extra |
| An uncaught exception inside a command | Re-emitted as the client's error event; with no listener for it, the process exits |
Logged by the command tree; the bot keeps running |
That last row is the one that bites beginners. I checked both behaviors while testing the bots below: a discord.js bot with no error listener exits the first time a command throws outside a try/catch, while discord.py logs the traceback and carries on.
How active is each library in 2026?
Both are around 11 years old and both are maintained. They differ in rhythm.
discord.js shipped 15 v14 releases between September 2025 and September 2026, in bursts rather than on a schedule, most recently 14.27.0 in July. Version 15 is being built on the main branch and published to npm as development builds, the newest on September 22, 2026; the stable line is still v14. The main branch had 250 commits from 49 contributors in those 12 months, and the v14 branch another 154.
discord.py releases a few times a year: 2.6.4 in October 2025, then 2.7.0 and 2.7.1 in February and March 2026. Work continues between releases. The master branch had 110 commits from 26 contributors in the same 12 months, 40 of them by its creator, Danny (Rapptz). The catch of that rhythm is that fixes wait. A fix for a deprecation warning that 2.7.1 raises under Python 3.14 (hidden by default) was merged in April 2026 and is still unreleased in late September.
The history matters because people still ask whether discord.py is dead. On August 28, 2021, Danny announced he was stepping down and put the repository in read-only mode, largely over Discord's privileged intents and the message content changes. On March 6, 2022, he resumed development, because Discord was about to shut down the API version the stable release used. The forks you still see, py-cord, nextcord and disnake, all started in that gap. More on them in py-cord, nextcord and disnake compared.
API coverage: can both do everything?
Yes, with a few months' difference in when new Discord features land. These are the recent additions that matter most, with the first version that supported them:
| Discord feature | discord.js | discord.py |
|---|---|---|
| Polls | 14.15.0, May 2024 | 2.4.0, June 2024 |
| Components v2 (containers, sections, text displays) | 14.19.0, April 2025 | 2.6.0, August 2025 |
| End-to-end encrypted voice (DAVE) | @discordjs/voice 0.19.0, August 2025 |
2.7.0, February 2026 |
| Checkbox and radio inputs in modals | 14.26.0, March 2026 | 2.7.0, February 2026 |
| User-installable apps | 14.16.0, September 2024 | 2.4.0, June 2024 |
Voice deserves a note. Discord has only allowed end-to-end encrypted voice connections since March 1, 2026, so a voice bot on a library version without DAVE support can no longer join calls. discord.py added it in 2.7.0 on February 27, 2026, two days before the deadline. discord.js had it in its voice package since August 2025, and since @discordjs/voice 0.19.1 the DAVE library is always installed. If you inherit an old music or voice bot, the library version is the first thing to check.
Both libraries need extra pieces for audio. In discord.js that is @discordjs/voice plus an Opus encoder; in discord.py it is pip install -U "discord.py[voice]". Both use FFmpeg to play common audio files such as MP3.
The same bot in both
Here is one bot written twice: a /roll command with two optional whole-number options, registered in one test server, with a private error message if something fails.
The Discord side is identical for both: create an application, copy its token, and invite the bot to a test server. Steps 1 to 4 of my discord.js tutorial walk through it, and none of it depends on the language. Both versions then read the same .env file, next to the code. GUILD_ID is your test server's ID, which you copy by right-clicking the server with Developer Mode on:
DISCORD_TOKEN=paste-your-bot-token-here
GUILD_ID=your-test-server-iddiscord.js
This version loads .env with Node's built-in --env-file flag. Run it on Node.js 22 or 24, the release lines that still get security fixes.
{
"name": "roll-bot",
"private": true,
"type": "commonjs",
"scripts": {
"start": "node --env-file=.env bot.js"
},
"dependencies": {
"discord.js": "^14.27.0"
}
}const { Client, Events, GatewayIntentBits, MessageFlags, SlashCommandBuilder } = require('discord.js');
const roll = new SlashCommandBuilder()
.setName('roll')
.setDescription('Roll some dice')
.addIntegerOption((option) =>
option.setName('dice').setDescription('How many dice (default 1)').setMinValue(1).setMaxValue(10),
)
.addIntegerOption((option) =>
option.setName('sides').setDescription('Sides per die (default 6)').setMinValue(2).setMaxValue(100),
);
const client = new Client({ intents: [GatewayIntentBits.Guilds] });
client.once(Events.ClientReady, async (readyClient) => {
// Register /roll in one test server. Guild commands update instantly.
const guild = await readyClient.guilds.fetch(process.env.GUILD_ID);
await guild.commands.set([roll]);
console.log(`Logged in as ${readyClient.user.tag}`);
});
client.on(Events.InteractionCreate, async (interaction) => {
if (!interaction.isChatInputCommand() || interaction.commandName !== 'roll') return;
try {
const dice = interaction.options.getInteger('dice') ?? 1;
const sides = interaction.options.getInteger('sides') ?? 6;
const rolls = Array.from({ length: dice }, () => 1 + Math.floor(Math.random() * sides));
const total = rolls.reduce((sum, value) => sum + value, 0);
await interaction.reply(`You rolled ${rolls.join(', ')} (total ${total}).`);
} catch (error) {
console.error(error);
if (!interaction.replied) {
await interaction.reply({ content: 'Something went wrong.', flags: MessageFlags.Ephemeral }).catch(console.error);
}
}
});
// discord.js re-emits errors from async listeners here; without this listener they crash the process.
client.on(Events.Error, console.error);
client.login(process.env.DISCORD_TOKEN);npm install
npm startdiscord.py
This version needs Python 3.10 or newer, because python-dotenv does. I tested it on 3.12 and 3.14.
discord.py>=2.7,<3
python-dotenv>=1.2import logging
import os
import random
import discord
from discord import app_commands
from dotenv import load_dotenv
load_dotenv()
GUILD = discord.Object(id=int(os.environ["GUILD_ID"]))
log = logging.getLogger("roll-bot")
class Bot(discord.Client):
def __init__(self):
# default() turns on every intent that isn't privileged.
super().__init__(intents=discord.Intents.default())
self.tree = app_commands.CommandTree(self)
async def setup_hook(self):
# Register the commands in one test server. Guild commands update instantly.
self.tree.copy_global_to(guild=GUILD)
await self.tree.sync(guild=GUILD)
bot = Bot()
@bot.event
async def on_ready():
log.info("Logged in as %s", bot.user)
@bot.tree.command(description="Roll some dice")
@app_commands.describe(dice="How many dice (default 1)", sides="Sides per die (default 6)")
async def roll(
interaction: discord.Interaction,
dice: app_commands.Range[int, 1, 10] = 1,
sides: app_commands.Range[int, 2, 100] = 6,
):
rolls = [random.randint(1, sides) for _ in range(dice)]
await interaction.response.send_message(f"You rolled {', '.join(map(str, rolls))} (total {sum(rolls)}).")
@bot.tree.error
async def on_command_error(interaction: discord.Interaction, error: app_commands.AppCommandError):
log.error("Command failed", exc_info=error)
if not interaction.response.is_done():
await interaction.response.send_message("Something went wrong.", ephemeral=True)
# root_logger=True sends our log lines through the same handler discord.py sets up.
bot.run(os.environ["DISCORD_TOKEN"], root_logger=True)python3 -m venv .venv
source .venv/bin/activate
pip install -r requirements.txt
python bot.pyOn Windows, activate the environment with .venv\Scripts\activate instead.
What the two versions show
- Options. discord.js describes each option in a builder chain, and the handler reads them back by name. discord.py reads the function signature:
dice: app_commands.Range[int, 1, 10] = 1is the option's type, limits and default in one place. That is why each command takes fewer lines in Python, even though this Python file is longer overall because of its class and logging setup. - Registration. Both register the command in one server on startup, because guild commands appear instantly (how slash command registration works). discord.py's official examples call
tree.sync()insetup_hookthe same way. In discord.js, bigger bots usually move this into a separate deploy script, as the tutorial does. - Intents. The discord.js bot lists exactly one intent,
Guilds. discord.py'sIntents.default()turns on every non-privileged intent, including message and typing events this bot never uses. Both are fine; discord.js makes you choose, discord.py chooses for you. - Errors. The discord.js version wraps the handler in
try/catchand still adds anerrorlistener, because without it an uncaught error ends the process. discord.py routes command errors to@bot.tree.error, and without that handler it logs them for you. - Secrets. Node reads
.envitself with--env-file. Python needs the python-dotenv package, or the variables set by your shell or host.
For a longer Python walkthrough with cogs, buttons and one error handler, see how to make a Discord bot in Python.
Typing and editor support
discord.js ships TypeScript definitions for both require and import. Autocomplete works in plain JavaScript in VS Code, and if you write TypeScript, checks like interaction.isChatInputCommand() narrow the type, so the editor knows which methods exist inside the if.
discord.py is type-hinted and ships the marker file that tells type checkers so, which means Pyright, mypy and VS Code's Pylance read its types directly. The type hints do double duty in slash commands, since they define the options Discord shows.
If static typing matters to you, both are solid. TypeScript's editor tooling is more widespread, and a TypeScript bot can share types with a TypeScript web dashboard.
Structure and ecosystem
discord.py includes more. discord.ext.commands gives you prefix commands and hybrid commands that work as both !command and /command (the prefix side needs the Message Content intent). discord.ext.tasks runs loops, for example checking an API every five minutes. Cogs group commands and listeners into classes you can reload, and discord.ui.View classes put a button's handler next to the button.
discord.js leaves more to you or to add-ons. You write the command handler yourself, which the official guide walks through, or use a framework such as Sapphire. Buttons are routed by their custom ID in your interactionCreate listener or through collectors. Scheduled jobs are plain setInterval or a cron package.
JavaScript's pull is the web. Discord logins, dashboards, webhooks and serverless functions are everyday JavaScript. On Rivals League, the Discord provisioning scripts, the Twitch go-live notifier and the Discord login all live in the same JavaScript and TypeScript repository as the league's website, and share its Supabase database. Discord's own getting-started tutorial is written in JavaScript too.
Python's pull is data. If the bot's real work is analysis, machine learning, pulling data from your own sources or scientific libraries, writing the bot in Python keeps it all in one language.
Performance: does the language matter?
Rarely. A Discord bot spends its time waiting for the network, and both libraries handle that the same way: one event loop, with Node's async model in discord.js and asyncio in discord.py. What limits a bot is Discord itself:
- 50 requests per second across the HTTP API for the whole bot, plus per-route limits. Both libraries queue requests and wait out rate limits for you.
- 120 gateway events per 60 seconds that a bot can send on one connection.
- Sharding is required from 2,500 servers. discord.js offers a sharding manager that runs shards as separate processes or threads, or internal sharding in one process. discord.py has
AutoShardedClient, which runs all shards in one process and is recommended from about 1,000 servers.
Memory depends on what the library caches far more than on the language. discord.js keeps up to 200 messages per channel by default and lets you tune every cache and sweep old entries. discord.py keeps 1,000 messages in total by default, and you can limit member caching and skip loading every member at startup.
Where language speed does show is CPU-heavy work inside the bot, such as image generation or crunching large files. That blocks the event loop in both, and the fix is the same: move it to a worker thread or process.
Hosting
A discord.js or discord.py bot is a long-running process that keeps a connection to Discord open, so it needs somewhere that stays on: a VPS, a container platform or a bot host. Both languages run on the same hosts. PebbleHost, for example, lists one $3.00-a-month plan with 1 GB of memory for Java, Python and Node.js bots (as of September 2026).
Serverless platforms that only run code per request are a different story. They can't hold a gateway connection, so a bot there has to receive slash commands over HTTP through an Interactions Endpoint URL instead, and both libraries are built around the gateway. That setup works in either language, but it is a different architecture from the bots on this page.
My guide to hosting a Discord bot 24/7 goes through free and paid options for both.
What about py-cord, nextcord and disnake?
These three forks started from discord.py's code in August 2021, while discord.py was paused. All three are still maintained:
| Fork | Import name | Latest release | Stable releases in the last 12 months | Python | GitHub stars (September 2026) |
|---|---|---|---|---|---|
| py-cord | discord |
2.8.1, July 25, 2026 | 5, plus 3 release candidates | 3.10 to 3.14 | 2,957 |
| nextcord | nextcord |
3.2.0, May 21, 2026 | 1 | 3.12 or newer | 1,260 |
| disnake | disnake |
2.12.1, July 22, 2026 | 2 | 3.10 or newer | 772 |
Each has caught up with the recent Discord features in its own time. DAVE voice support arrived in disnake 2.12.0 (February 2026), py-cord 2.8.0 (May 2026) and nextcord 3.2.0 (May 2026). Components v2 arrived in disnake 2.11.0 (September 2025), was overhauled in py-cord 2.7.0 (December 2025), and arrived as initial, lower-level support in nextcord 3.2.0.
Watch the import names. py-cord installs as discord, the same package name as discord.py, so the two can't live in the same environment, and a tutorial written for one can fail on the other in confusing ways. Check which one pip list shows before debugging anything else.
For a new Python bot in 2026, I'd pick discord.py: its community is by far the largest of the four (about 16,200 GitHub stars against 2,957 for py-cord, the biggest fork), and the reason the forks were created, discord.py's shutdown, no longer applies. Stay on a fork if your bot already uses it and works. Each fork added its own slash command API, so moving to discord.py means rewriting that part of the code.
How to choose
Pick discord.js if:
- you know JavaScript or TypeScript, or the bot sits next to a website, dashboard or API written in it
- you want static types with the most common tooling
- you value frequent releases that follow Discord's changes closely
Pick discord.py if:
- you know Python, or the bot's real work uses Python libraries
- you want the shortest command code, with options declared in the function signature
- you'd use its built-in task loops, cogs and hybrid commands
If the bot is for a business, a league or a paid community, the library matters less than who maintains the code after launch. Whichever you choose, keep it on a current version: Discord changes things every year, and the voice encryption deadline in March 2026 cut off voice bots running library versions without DAVE.
How I tested the two bots
I can't log in to Discord from a test script without a real token, so each file ran unchanged against a fake Discord connection: login and every HTTP request were replaced by recorders, and interaction payloads shaped like Discord's went through each library's own interaction handling.
| Check | discord.js 14.27.0, Node.js 24.15.0 | discord.py 2.7.1, Python 3.12.13 and 3.14.3 |
|---|---|---|
| Startup | Token read from .env; only the Guilds intent |
Token and server ID read from .env by python-dotenv 1.2.3; default intents, none privileged |
| Registration | PUT to the server's command route with /roll, options 1 to 10 and 2 to 100, both optional |
Same route and the same option limits from tree.sync() |
/roll with defaults |
One die from 1 to 6 | One die from 1 to 6 |
/roll dice:3 sides:20 |
Three values from 1 to 20 and the right total | Three values from 1 to 20 and the right total |
| A command that raises an error | Error logged, private "Something went wrong." | Error logged through @bot.tree.error, private "Something went wrong." |
| Warnings | None | None from the bot's code. The library itself raises one hidden DeprecationWarning on each version: about audioop on 3.12, and about asyncio.iscoroutinefunction on 3.14 (the one fixed on master but not yet released) |
I also installed requirements.txt into a fresh virtual environment to check that it resolves to discord.py 2.7.1 and python-dotenv 1.2.3.
If you'd rather not maintain a bot in either language, I build custom Discord bots on your own application and hand over the code, usually in discord.js. If you already have a discord.js or discord.py bot that broke after an update or stopped joining voice, Bot Rescue gets it running again for a fixed $149, and you pay nothing if I can't fix it.