How to Build a Discord Bot in Go
You want a bot that lives in your Discord server. It watches messages, answers commands, and maybe moderates chat. You've seen Python bots that sleep for hours or Node.js bots that require a dozen dependencies. You want something that starts fast, uses little memory, and just works. Go makes this straightforward. You write a small program, connect to the Discord API, and handle events as they arrive.
The switchboard analogy
Think of the Discord API as a busy switchboard. Your bot is an operator sitting at a desk. The switchboard rings whenever something happens in the server: a message, a join, a reaction. Your operator picks up, checks what the call is about, and takes action.
Discord communicates over WebSockets. This is a persistent connection. Your bot opens the connection, stays open, and receives a stream of JSON events. You don't poll the API every second. The API pushes updates to you. You need a token to authenticate. The token is like a password that proves your bot is allowed to talk. The library handles the handshake, the heartbeat, and the JSON parsing. You focus on the logic: "If message is 'ping', reply 'pong'."
Discord pushes events. You react. The connection stays open.
Minimal example
Here's the smallest bot that connects and replies to a message. It initializes a session, registers a handler, and blocks forever.
package main
import (
"log"
"github.com/bwmarrin/discordgo"
)
// main initializes the bot session and connects to Discord.
func main() {
// New creates a session. The "Bot " prefix is mandatory.
dg, err := discordgo.New("Bot YOUR_TOKEN_HERE")
if err != nil {
log.Fatalf("Token error: %v", err)
}
// AddHandler registers the message callback.
dg.AddHandler(messageHandler)
// Open starts the WebSocket and blocks until connected.
if err := dg.Open(); err != nil {
log.Fatalf("Connection failed: %v", err)
}
// Block forever to keep the process alive.
select {}
}
The handler runs every time a message arrives. You check the content and reply.
// messageHandler processes incoming messages.
func messageHandler(s *discordgo.Session, m *discordgo.MessageCreate) {
// Ignore the bot's own messages to prevent infinite loops.
if m.Author.ID == s.State.User.ID {
return
}
// Reply only to the specific command.
if m.Content == "ping" {
s.ChannelMessageSend(m.ChannelID, "pong")
}
}
Walkthrough
You start by initializing the module. Run go mod init discord-bot in your project directory. This creates the go.mod file. Then you fetch the library. go get github.com/bwmarrin/discordgo downloads the code and records the dependency. The compiler checks types before you run. If you pass a string where an int is expected, you get a type mismatch error immediately.
At runtime, discordgo.New builds a session object. It doesn't connect yet. AddHandler stores your function in a list. Open performs the handshake. It sends a token, receives a heartbeat interval, and upgrades the connection to a WebSocket. The library spawns a goroutine to read messages from the socket and dispatch them to your handlers. Your main function blocks on select {}, keeping the process alive.
The session object holds state. s.State.User.ID gives you the bot's user ID. The library caches guilds, channels, and members as it receives events. You can query this cache instead of making API calls. Caching reduces latency and saves rate limits.
Convention aside: if err != nil { return err } is verbose by design. The community accepts the boilerplate because it makes the unhappy path visible. You check the error from New and Open. If the token is bad, you log and exit. You don't swallow errors.
The library handles the heartbeat. You handle the logic.
Realistic handler
Real bots check for a prefix and handle send errors. Here's a handler that parses commands and logs failures.
import (
"log"
"strings"
"github.com/bwmarrin/discordgo"
)
// handleCommand processes messages starting with "!".
func handleCommand(s *discordgo.Session, m *discordgo.MessageCreate) {
// Ignore bot messages and those without the prefix.
if m.Author.Bot || !strings.HasPrefix(m.Content, "!") {
return
}
// Extract the command name.
// SplitN limits splits to 2, keeping the rest as arguments.
parts := strings.SplitN(m.Content, " ", 2)
cmd := parts[0][1:]
// Send the reply and capture the error.
// ChannelMessageSend returns the message struct and an error.
_, err := s.ChannelMessageSend(m.ChannelID, "Got command: "+cmd)
if err != nil {
log.Printf("Send failed: %v", err)
}
}
The handler ignores messages from other bots. It checks for the ! prefix. strings.SplitN splits the content into the command and the arguments. You remove the prefix with parts[0][1:]. ChannelMessageSend returns the sent message and an error. You discard the message with _ and check the error. If the send fails, you log it.
Convention aside: _ discards a value intentionally. _, err := ... says "I considered the return value and chose to drop it". Use it sparingly with errors, but it's standard for return values you don't need.
Parse carefully. Handle errors. Don't assume the send succeeds.
Graceful shutdown and context
Bots run for a long time. When you stop the process, you want it to close the connection cleanly. If you kill the process abruptly, Discord might mark the bot as offline slowly. Graceful shutdown sends a close frame and releases resources.
You can listen for OS signals and close the session.
import (
"os"
"os/signal"
"syscall"
)
// setupShutdown listens for interrupt signals and closes the session.
func setupShutdown(dg *discordgo.Session) {
// Create a channel to receive signals.
sig := make(chan os.Signal, 1)
signal.Notify(sig, os.Interrupt, syscall.SIGTERM)
// Block until a signal arrives, then close the session.
go func() {
<-sig
log.Println("Shutting down...")
dg.Close()
}()
}
Call setupShutdown(dg) after Open. The goroutine blocks on the signal channel. When you press Ctrl+C, the channel receives os.Interrupt. The goroutine calls dg.Close() and the program exits.
When your bot calls external APIs, pass a context. This allows you to cancel requests if the bot shuts down. context.Context always goes as the first parameter, conventionally named ctx. Functions that take a context should respect cancellation and deadlines.
Graceful shutdown prevents data loss. Context prevents hanging requests.
Pitfalls and errors
Tokens are secrets. Never commit them to git. Use environment variables. os.Getenv("DISCORD_TOKEN") reads the token from the environment. If you hardcode the token, you risk exposing it. The compiler won't stop you, but your security will suffer.
Discord enforces rate limits. If you spam messages, the API returns a 429 error. The library handles global rate limits automatically, but you can still hit per-endpoint limits. If you exceed the limit, the library queues requests. If the queue fills, you get a rate limit exceeded error. Design your bot to respect limits. Batch updates when possible.
Self-messages create loops. If you reply to your own message, you trigger another event. This creates a loop. Always check m.Author.ID == s.State.User.ID. The compiler rejects the program with loop variable i captured by func literal if you forget to capture loop variables in closures, but that's a different issue. For bots, the loop is logical, not syntactic.
Goroutine leaks happen when the goroutine waits on a channel that never gets closed. Always have a cancellation path. If you spawn a goroutine per message, ensure it finishes or can be cancelled. The worst bot bug is the silent loop. Check your own ID.
Secure tokens. Respect limits. Break loops.
Decision matrix
Use discordgo when you need a full-featured bot with WebSocket support and event handling. Use a REST-only approach when your bot reacts to webhooks or sends notifications without listening to chat. Use a lightweight library like lavalink client when your bot focuses on music playback and delegates audio processing to a separate service. Use discordgo with a worker pool when you have heavy processing per message and need to bound concurrency to protect downstream services. Use plain sequential code when you don't need concurrency: the simplest thing that works is usually the right thing.