← all articles
~/blog/aiogram-3-tutorial-2025.md
April 10, 2025·12 min

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.

AiogramPythonTutorialTelegramBot

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 filtersF.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 paymentssend_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.

// need help with a project?

I build backends, bots, automations and AI tools.

get in touch →