Lavalink

Connecting a discord.py Bot to Lavalink With Wavelink

Build a discord.py music bot on Lavalink v4 with Wavelink 3 — connecting nodes, a /play command with a queue, controls, events, autoplay and common fixes.

On this page
  1. Install
  2. Connect to Lavalink in setup_hook
  3. What happens when the bot joins voice
  4. A /play command
  5. Controls
  6. Events
  7. Resilience
  8. Troubleshooting
  9. Where to host
  10. Summary

Wavelink is the most popular Lavalink client for discord.py. Version 3 is built for Lavalink v4 and includes a player with a built-in queue, autoplay and typed events, which means a working music bot takes surprisingly little code. This guide walks through it.

You’ll need discord.py 2.x, Python 3.10 or newer (Wavelink 3’s requirement), and a Lavalink v4 node’s host, port and password.

Install

# requirements.txt
discord.py>=2.4,<3
wavelink>=3.4,<4

Environment variables:

DISCORD_TOKEN=...
LAVALINK_URI=http://your-node-host:2333
LAVALINK_PASSWORD=your-node-password

Kerit Cloud supports Python 3.9–3.12; choose 3.10 or newer for Wavelink 3.

import logging
import os

import discord
import wavelink
from discord import app_commands
from discord.ext import commands


class MusicBot(commands.Bot):
    def __init__(self):
        intents = discord.Intents.default()   # includes voice_states
        super().__init__(command_prefix=commands.when_mentioned, intents=intents)

    async def setup_hook(self):
        node = wavelink.Node(uri=os.environ["LAVALINK_URI"], password=os.environ["LAVALINK_PASSWORD"])
        await wavelink.Pool.connect(nodes=[node], client=self, cache_capacity=100)

    async def on_wavelink_node_ready(self, payload: wavelink.NodeReadyEventPayload):
        logging.info("Lavalink node %s ready (resumed=%s)", payload.node.identifier, payload.resumed)


bot = MusicBot()

Intents.default() includes voice_states, which voice needs. cache_capacity enables a small cache of search results, saving repeated lookups for popular queries.

What happens when the bot joins voice

It helps to understand the handshake, because most “joins but no sound” problems happen here.

  1. Your bot asks Discord’s gateway to join a voice channel.
  2. Discord replies with two events: a voice state update (your bot’s session in that channel) and a voice server update (which regional voice server to use, plus a token).
  3. The client library forwards both to Lavalink. This is why the voice states intent matters — without it, the library never sees the events.
  4. Lavalink connects to that voice server itself and starts streaming audio once you play a track.

Your bot never touches the audio. If the bot shows as connected but there’s silence, the problem is almost always on the Lavalink side — a source that failed to load, a node that can’t reach the voice server, or missing Speak permission — so the node’s logs and your client’s exception events are the first place to look.

A /play command

@bot.tree.command(description="Play a song or add it to the queue.")
@app_commands.describe(query="A search term or a link")
async def play(interaction: discord.Interaction, query: str):
    if not interaction.user.voice:
        return await interaction.response.send_message("Join a voice channel first.", ephemeral=True)

    await interaction.response.defer()

    player: wavelink.Player | None = interaction.guild.voice_client
    if player is None:
        player = await interaction.user.voice.channel.connect(cls=wavelink.Player, self_deaf=True)
        player.autoplay = wavelink.AutoPlayMode.partial   # play queued tracks automatically

    tracks = await wavelink.Playable.search(query)
    if not tracks:
        return await interaction.followup.send("Nothing found.")

    if isinstance(tracks, wavelink.Playlist):
        added = await player.queue.put_wait(tracks)
        await interaction.followup.send(f"Queued {added} tracks from **{tracks.name}**.")
    else:
        track = tracks[0]
        await player.queue.put_wait(track)
        await interaction.followup.send(f"Queued **{track.title}**.")

    if not player.playing:
        await player.play(player.queue.get(), volume=40)

How it works:

  • connect(cls=wavelink.Player) joins voice with a Wavelink player instead of discord.py’s default voice client.
  • Playable.search() returns a list of tracks or a Playlist. Plain text searches YouTube by default through your node’s YouTube plugin; pass source=wavelink.TrackSource.SoundCloud or use a prefix like scsearch: for other sources, or spsearch: if the node runs LavaSrc with Spotify.
  • AutoPlayMode.partial makes the player play the next queued track automatically when one ends, without Wavelink’s recommendation-based autoplay. Use AutoPlayMode.enabled if you want it to find related tracks when the queue runs out, or disabled to control everything yourself.
  • Deferring avoids Discord’s three-second interaction timeout while searching.

Controls

@bot.tree.command(description="Skip the current track.")
async def skip(interaction: discord.Interaction):
    player: wavelink.Player | None = interaction.guild.voice_client
    if not player or not player.playing:
        return await interaction.response.send_message("Nothing is playing.", ephemeral=True)
    await player.skip(force=True)
    await interaction.response.send_message("Skipped.")


@bot.tree.command(description="Pause or resume playback.")
async def pause(interaction: discord.Interaction):
    player: wavelink.Player = interaction.guild.voice_client
    await player.pause(not player.paused)
    await interaction.response.send_message("Paused." if player.paused else "Resumed.")


@bot.tree.command(description="Stop and leave the channel.")
async def stop(interaction: discord.Interaction):
    player: wavelink.Player = interaction.guild.voice_client
    await player.disconnect()
    await interaction.response.send_message("Bye!")

Volume is await player.set_volume(50). Filters are set through player.filters and applied with await player.set_filters(filters) — see Lavalink audio filters.

Events

Wavelink dispatches events on your bot with an on_wavelink_ prefix:

@bot.event
async def on_wavelink_track_start(payload: wavelink.TrackStartEventPayload):
    track = payload.track
    logging.info("Playing %s in guild %s", track.title, payload.player.guild.id)


@bot.event
async def on_wavelink_track_exception(payload: wavelink.TrackExceptionEventPayload):
    logging.warning("Track failed: %s", payload.exception)


@bot.event
async def on_wavelink_inactive_player(player: wavelink.Player):
    await player.disconnect()   # leave when idle

The inactive-player event fires after a player has been idle for a configurable time (player.inactive_timeout, in seconds), which makes leaving empty channels easy. Leaving idle channels saves node resources and is good etiquette.

Resilience

  • Queues live in memory. A bot restart loses them. If that matters, save each guild’s queue (track identifiers or URIs) to Redis or a database and rebuild on startup.
  • Node disconnects. Wavelink reconnects to nodes automatically. Log node events so you know when it happens, and consider a second node for redundancy — see running multiple Lavalink nodes.
  • Close cleanly. On shutdown, disconnect players so the bot doesn’t leave ghost connections. discord.py’s close() handles voice clients; override it if you add your own resources.

Troubleshooting

wavelink.InvalidNodeException or “no nodes available” — the node didn’t connect. Check the URI (include http:// and the port), the password, and that your server can reach the node’s port.

AuthorizationFailedException — wrong password.

The bot joins but stays silent — look for track exception events. The node’s source may be failing (often an outdated YouTube plugin), or the bot lacks Connect and Speak permissions in the channel.

ModuleNotFoundError: No module named 'wavelink' — wavelink isn’t in requirements.txt, or the Python version is below 3.10.

Commands work locally but not on the server — environment variables aren’t set on the server, or the server’s firewall blocks outbound access to the node.

Where to host

Run the bot close to Discord’s US East infrastructure and the node close to your listeners. Kerit Cloud’s managed Lavalink plans give you a URI and password ready for Wavelink, with LavaSrc and SponsorBlock pre-installed, and the Discord bot plans host the bot. Running a music bot explains why the two should be separate.

Summary

Wavelink 3 connects discord.py to Lavalink v4 with wavelink.Pool.connect() in setup_hook. Join voice with connect(cls=wavelink.Player), search with Playable.search(), use the built-in queue and AutoPlayMode.partial for automatic playback, and handle events such as track exceptions and inactive players. Persist queues if they must survive restarts, and host the node near your listeners.