Aiogram 3 Tutorial: Build a Telegram Bot in Python (2025)
Step-by-step guide to building a Telegram bot with Aiogram 3 — handlers, FSM, inline keyboards, and deployment. No filler.
Aiogram 3 is the async-first Python framework for Telegram bots. I've used it on 20+ production projects. Here's everything you need to go from nothing to a deployed bot, without wading through outdated tutorials.
Why Aiogram 3 specifically
There are a few Python Telegram bot libraries. Aiogram 3 wins for production work because:
- Async-first — built on asyncio, handles thousands of updates efficiently
- Router system — split your handlers across modules cleanly
- Magic filters —
F.text == "hello",F.photo,F.from_user.id == 123456— expressive and readable - FSM — built-in finite state machine for multi-step dialogs
- Type safety — full type hints throughout
The older python-telegram-bot works too, but Aiogram 3's architecture is cleaner for anything beyond toy projects.
Setup
pip install aiogram
Get a bot token from @BotFather — send /newbot, follow the prompts.
Project structure I use:
mybot/
├── bot.py # entry point
├── config.py # token + settings
├── handlers/
│ ├── start.py
│ └── orders.py
├── keyboards/
│ └── kb.py
└── states/
└── states.py
config.py:
import os
BOT_TOKEN = os.getenv("BOT_TOKEN", "your_token_here")
First bot: /start handler
# bot.py
import asyncio
from aiogram import Bot, Dispatcher
from aiogram.filters import CommandStart
from aiogram.types import Message
bot = Bot(token="YOUR_TOKEN")
dp = Dispatcher()
@dp.message(CommandStart())
async def cmd_start(message: Message):
await message.answer(f"Hello, {message.from_user.first_name}!")
async def main():
await dp.start_polling(bot)
if __name__ == "__main__":
asyncio.run(main())
Run it, send /start to your bot. That's the base.
Routers: keeping your code organized
In Aiogram 3, Router is the key abstraction. Instead of registering everything on the Dispatcher, you create routers per module and include them:
# handlers/start.py
from aiogram import Router, F
from aiogram.filters import CommandStart
from aiogram.types import Message
router = Router()
@router.message(CommandStart())
async def cmd_start(message: Message):
await message.answer("Welcome!")
@router.message(F.text == "help")
async def help_text(message: Message):
await message.answer("Here's how this bot works...")
# bot.py
from handlers import start, orders
dp.include_router(start.router)
dp.include_router(orders.router)
This keeps large bots maintainable. Each handler file stays focused.
Magic filters: F.
The F object is where Aiogram 3 really shines:
from aiogram import F
# Match exact text
@router.message(F.text == "Buy")
# Match photo messages
@router.message(F.photo)
# Match specific user
@router.message(F.from_user.id == 123456789)
# Combine filters
@router.message(F.text.startswith("order:") & F.from_user.is_premium)
# Negate
@router.message(~F.text.in_({"stop", "cancel"}))
No more if message.text == "Buy": inside catch-all handlers. Filters are declared, readable, and composable.
Inline keyboards and callbacks
# keyboards/kb.py
from aiogram.utils.keyboard import InlineKeyboardBuilder
from aiogram.types import InlineKeyboardMarkup
def main_kb() -> InlineKeyboardMarkup:
b = InlineKeyboardBuilder()
b.button(text="📦 Products", callback_data="products")
b.button(text="📋 My orders", callback_data="my_orders")
b.button(text="💬 Contact", url="https://t.me/yourusername")
b.adjust(2, 1) # 2 buttons in row 1, 1 in row 2
return b.as_markup()
# handler
from aiogram.types import CallbackQuery
@router.message(CommandStart())
async def cmd_start(message: Message):
await message.answer("Choose:", reply_markup=main_kb())
@router.callback_query(F.data == "products")
async def show_products(callback: CallbackQuery):
await callback.answer() # dismiss the loading spinner
await callback.message.edit_text("Here are our products...", reply_markup=products_kb())
Always call callback.answer() first — without it, the button stays in loading state for 30 seconds.
FSM: multi-step dialogs
FSM (Finite State Machine) is how you handle flows that span multiple messages — order forms, registration, surveys.
# states/states.py
from aiogram.fsm.state import State, StatesGroup
class OrderForm(StatesGroup):
waiting_name = State()
waiting_phone = State()
waiting_description = State()
# handlers/orders.py
from aiogram.fsm.context import FSMContext
from states.states import OrderForm
@router.callback_query(F.data == "new_order")
async def start_order(callback: CallbackQuery, state: FSMContext):
await state.set_state(OrderForm.waiting_name)
await callback.answer()
await callback.message.answer("What's your name?")
@router.message(OrderForm.waiting_name)
async def got_name(message: Message, state: FSMContext):
await state.update_data(name=message.text)
await state.set_state(OrderForm.waiting_phone)
await message.answer("Your phone number:")
@router.message(OrderForm.waiting_phone)
async def got_phone(message: Message, state: FSMContext):
await state.update_data(phone=message.text)
await state.set_state(OrderForm.waiting_description)
await message.answer("Describe what you need:")
@router.message(OrderForm.waiting_description)
async def got_description(message: Message, state: FSMContext):
data = await state.get_data()
await state.clear()
await message.answer(
f"✅ Order received!\n"
f"Name: {data['name']}\n"
f"Phone: {data['phone']}\n"
f"Task: {message.text}"
)
The FSMContext is injected automatically by Aiogram. State persists between messages. By default it uses MemoryStorage (lost on restart) — for production switch to RedisStorage.
Middleware: run code on every update
from aiogram import BaseMiddleware
from aiogram.types import TelegramObject
class LoggingMiddleware(BaseMiddleware):
async def __call__(self, handler, event: TelegramObject, data: dict):
print(f"Update from user {event.from_user.id}")
return await handler(event, data)
dp.update.middleware(LoggingMiddleware())
I use middleware to auto-save users to the database on first contact — runs before any handler, never forget to add it.
Deployment: systemd on VPS
The simplest production setup: Ubuntu VPS, systemd service. No Docker needed for simple bots.
# /etc/systemd/system/mybot.service
[Unit]
Description=My Telegram Bot
After=network.target
[Service]
User=ubuntu
WorkingDirectory=/home/ubuntu/mybot
Environment="BOT_TOKEN=your_token"
ExecStart=/home/ubuntu/mybot/.venv/bin/python bot.py
Restart=always
RestartSec=5
[Install]
WantedBy=multi-user.target
sudo systemctl enable mybot
sudo systemctl start mybot
sudo systemctl status mybot # check it's running
journalctl -u mybot -f # tail the logs
Your bot now survives VPS reboots and restarts automatically on crashes.
What to learn next
Once you're comfortable with the basics:
- PostgreSQL + aiosqlite/asyncpg — most bots need a database eventually
- Telegram Stars payments —
send_invoice(),pre_checkout_query,successful_payment - Webhooks — faster than polling for high-load bots, requires a public HTTPS domain
- Telegram Mini App — build a full web UI that opens inside Telegram
- Broadcast system — send messages to all users without hitting rate limits
The documentation at docs.aiogram.dev is solid. The Aiogram Telegram community (@aiogram) is active and helpful.
Build something real, deploy it, watch it run. That's how you actually learn this.
share