How to Build a Telegram Bot in Go

Web
You want a bot that replies when someone types `/start`. You get a token from BotFather, drop it into a Go file, and run it. The terminal prints `Authorized on account`. You message the bot. It replies `Hello!`. It works. Then you try to add a second command, or handle a photo, or run a database que

The first message

You want a bot that replies when someone types /start. You get a token from BotFather, drop it into a Go file, and run it. The terminal prints Authorized on account. You message the bot. It replies Hello!. It works. Then you try to add a second command, or handle a photo, or run a database query, and the single loop starts choking. The gap between a working prototype and a bot that survives real traffic is where Go's concurrency model and HTTP mechanics actually matter.

How Telegram delivers updates

Telegram treats every interaction with your bot as an update. A text message, a button click, a new group member, or a callback query all arrive as JSON payloads. Your code needs a way to receive them. There are two paths. Polling means your program repeatedly asks Telegram's servers for new updates. Webhooks mean you give Telegram a URL, and it POSTs updates to your server whenever something happens.

Think of polling like checking a physical mailbox. You walk over, look inside, grab what's there, and walk back. If nothing arrived, you wait a minute and check again. Webhooks are a doorbell. You stay inside, and the carrier knocks when something arrives. Polling requires zero infrastructure. You just run a binary. Webhooks require a public URL, TLS certificates, and a long-running HTTP server. For learning and small projects, polling is the straightforward path.

The polling loop

Here's the minimal polling setup. The library handles the HTTP requests, JSON parsing, and offset tracking. You just read from a channel.

package main

import (
	"fmt"
	tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5"
)

func main() {
	// Initialize the client with the token from BotFather
	bot, err := tgbotapi.NewBotAPI("YOUR_BOT_TOKEN")
	if err != nil {
		panic(err)
	}

	// Enable debug logging to see raw API requests and responses
	bot.Debug = true
	fmt.Println("Authorized on account", bot.Self.UserName)

	// Create an update configuration starting from offset 0
	u := tgbotapi.NewUpdate(0)
	u.Timeout = 60 // Wait up to 60 seconds for new updates

	// GetUpdatesChan blocks and returns a channel of parsed updates
	updates := bot.GetUpdatesChan(u)

	// Range over the channel to process updates as they arrive
	for update := range updates {
		if update.Message != nil {
			msg := tgbotapi.NewMessage(update.Message.Chat.ID, "Hello!")
			msg.DisableNotification = true
			bot.Send(msg)
		}
	}
}

The GetUpdatesChan call does the heavy lifting. It spawns a background goroutine that continuously calls Telegram's getUpdates method. When the API returns a batch of updates, the goroutine unmarshals them and sends each one down the channel. Your for loop receives them one by one. The channel acts as a synchronized bridge between the network thread and your business logic.

Goroutines are cheap. Channels are not magic.

What happens under the hood

When GetUpdatesChan starts, it makes a long-polling HTTP request to https://api.telegram.org/bot<token>/getUpdates. The Timeout parameter tells Telegram to hold the connection open for up to 60 seconds instead of returning immediately with an empty list. This reduces API call volume and keeps latency low.

Telegram responds with a JSON array. The library parses it into a slice of tgbotapi.Update structs. Each struct contains fields for different update types: Message, EditedMessage, CallbackQuery, ChannelPost, and so on. Only one of these fields is populated per update. The library tracks the highest update_id it has seen and automatically increments the offset for the next request. You never manually manage the offset when using GetUpdatesChan.

The channel returned by GetUpdatesChan is unbuffered by default in older library versions, but modern releases buffer it slightly to prevent backpressure from stalling the HTTP request. If your loop blocks for too long, the internal goroutine will eventually drop updates or panic depending on the library version. That's why you should never run slow operations directly inside the for update := range updates loop.

Trust the channel. Keep the loop fast.

Handling real conversations

A production bot needs routing, error handling, and concurrency. You want to handle /start, /help, and arbitrary text. You want to log failures instead of panicking. You want to process messages concurrently so one slow database query doesn't delay replies to other users.

Here's a realistic handler structure. It separates routing from execution and uses goroutines safely.

// handleUpdate processes a single Telegram update concurrently
func handleUpdate(bot *tgbotapi.BotAPI, update tgbotapi.Update) {
	if update.Message == nil {
		return
	}

	// Route based on the first word in the message text
	switch update.Message.Text {
	case "/start":
		bot.Send(tgbotapi.NewMessage(update.Message.Chat.ID, "Welcome! Use /help for commands."))
	case "/help":
		bot.Send(tgbotapi.NewMessage(update.Message.Chat.ID, "Available commands: /start, /help"))
	default:
		bot.Send(tgbotapi.NewMessage(update.Message.Chat.ID, "I only understand commands."))
	}
}

You call this function from the main loop, spawning a goroutine per update. This keeps the polling channel flowing.

func main() {
	bot, err := tgbotapi.NewBotAPI("YOUR_BOT_TOKEN")
	if err != nil {
		panic(err)
	}

	u := tgbotapi.NewUpdate(0)
	u.Timeout = 60
	updates := bot.GetUpdatesChan(u)

	// Process each update in its own goroutine to avoid blocking the poller
	for update := range updates {
		go handleUpdate(bot, update)
	}
}

The if err != nil { return err } pattern is verbose by design. The Go community accepts the boilerplate because it makes the unhappy path visible. If bot.Send fails, you should log it or retry, not ignore it. Network drops happen. Telegram rate limits happen. Always capture the error return value from API calls.

Public names start with a capital letter. Private start lowercase. No keywords like public or private. The handleUpdate function is unexported because it only lives inside your main package. If you extract it to a separate file, keep it lowercase unless you need other packages to call it.

Don't fight the type system. Route early, handle errors explicitly, and keep the poller unblocked.

Where the runtime trips you up

New Go developers hit three predictable walls with Telegram bots.

First, nil pointer dereferences. The Update struct contains multiple optional fields. update.Message is nil for callback queries, channel posts, or edited messages. Accessing update.Message.Text without checking update.Message != nil panics at runtime. The compiler won't catch it because Message is a pointer field, not a missing variable. Always guard optional fields before dereferencing.

Second, goroutine leaks. Spawning go handleUpdate(bot, update) for every message works until you add a blocking operation inside the handler. If you wait on a channel that never closes, or block on a database connection that's exhausted, the goroutine hangs. The update loop keeps spawning more. Memory climbs. The process dies. Always attach a timeout or cancellation path to long-running tasks.

Third, compiler rejections from missing imports or type mismatches. Forget to import the bot library and you get undefined: tgbotapi from the compiler. Pass a string where an int is expected and you see cannot use x (untyped string constant) as int value in argument. The compiler is strict about types because Go compiles to machine code ahead of time. Fix the type, not the compiler.

The worst goroutine bug is the one that never logs. Add structured logging to every handler entry and exit.

Choosing your update strategy

Use polling when you are prototyping, running on a local machine, or managing a bot with low traffic. Use webhooks when you need sub-second latency, want to reduce API call volume, or are deploying behind a load balancer with TLS termination. Use a worker pool when you expect high message throughput and need to bound concurrency to protect your database or external APIs. Use sequential handling when your bot performs simple, fast replies and you want deterministic execution order for a single chat.

Context is plumbing. Run it through every long-lived call site.

Where to go next