all articles
April 10, 2025·updated September 23, 2026·12 min

Aiogram 3 tutorial: build a Telegram bot with Python

Build an Aiogram 3 bot: configure its token safely, add a Router, inline buttons and an FSM, test the flow, and run it with systemd.

AiogramPythonTutorialTelegramBots

By Shcoder, independent developer

In this tutorial, we will build a runnable Telegram bot with Aiogram 3: it responds to /start, shows inline buttons, handles a callback, and collects a short request through FSM. The bot token comes from an environment variable, and every handler is connected through a Router.

If you prefer guided exercises, start with the free Aiogram 3 workshop. It has six lessons, from the first reply to FSM and deployment on a VPS. Course lessons are in Russian.

Set up the project

You need Python 3.10 or newer. Create a bot with @BotFather, then prepare a virtual environment:

mkdir aiogram-demo
cd aiogram-demo
python -m venv .venv
source .venv/bin/activate
python -m pip install --upgrade pip
python -m pip install "aiogram==3.31.0"

On Windows PowerShell, activate the environment with \.venv\Scripts\Activate.ps1. Do not put the token in source code or Git. Pass it to the process through the environment:

# macOS / Linux
read -rsp "Bot token: " BOT_TOKEN; echo
export BOT_TOKEN
python bot.py
# Windows PowerShell
$secure = Read-Host "Bot token" -AsSecureString
$env:BOT_TOKEN = [System.Net.NetworkCredential]::new("", $secure).Password
python bot.py

Complete example: bot, buttons and FSM

Create bot.py. This example keeps the startup, callback and form handlers in one router. In a real project, you can split it into modules while keeping explicit router registration in the Dispatcher.

import asyncio
import os

from aiogram import Bot, Dispatcher, F, Router
from aiogram.filters import Command, CommandStart
from aiogram.fsm.context import FSMContext
from aiogram.fsm.state import State, StatesGroup
from aiogram.types import CallbackQuery, InlineKeyboardMarkup, Message
from aiogram.utils.keyboard import InlineKeyboardBuilder


class OrderForm(StatesGroup):
    name = State()
    contact = State()


router = Router(name=__name__)


def main_keyboard() -> InlineKeyboardMarkup:
    builder = InlineKeyboardBuilder()
    builder.button(text="Show catalog", callback_data="catalog")
    builder.button(text="Send a request", callback_data="order:start")
    builder.adjust(1)
    return builder.as_markup()


@router.message(CommandStart())
async def start(message: Message, state: FSMContext) -> None:
    await state.clear()
    name = message.from_user.full_name if message.from_user else "there"
    await message.answer(f"Hello, {name}! Choose an action:", reply_markup=main_keyboard())


@router.callback_query(F.data == "catalog")
async def show_catalog(callback: CallbackQuery) -> None:
    if not isinstance(callback.message, Message):
        await callback.answer("Open the bot in a private chat", show_alert=True)
        return

    await callback.answer()
    await callback.message.edit_text("Demo catalog: item A, item B", reply_markup=main_keyboard())


@router.callback_query(F.data == "order:start")
async def start_order(callback: CallbackQuery, state: FSMContext) -> None:
    if not isinstance(callback.message, Message):
        await callback.answer("Open the bot in a private chat", show_alert=True)
        return

    await callback.answer()
    await state.set_state(OrderForm.name)
    await callback.message.answer("What should we call you? Send /cancel to stop.")


@router.message(Command("cancel"))
async def cancel_order(message: Message, state: FSMContext) -> None:
    await state.clear()
    await message.answer("The form was cancelled. Use the request button to start again.")


@router.message(OrderForm.name, F.text)
async def get_name(message: Message, state: FSMContext) -> None:
    name = message.text.strip()
    if not name:
        await message.answer("The name cannot be empty. Please try again.")
        return

    await state.update_data(name=name)
    await state.set_state(OrderForm.contact)
    await message.answer("For testing, enter a sample contact such as @demo. Do not send real personal data.")


@router.message(OrderForm.name)
async def reject_non_text_name(message: Message) -> None:
    await message.answer("Send your name as a text message.")


@router.message(OrderForm.contact, F.text)
async def get_contact(message: Message, state: FSMContext) -> None:
    contact = message.text.strip()
    if not contact:
        await message.answer("The contact cannot be empty. Please try again.")
        return

    await state.update_data(contact=contact)
    data = await state.get_data()
    await state.clear()

    # In a real service, persist the request before sending a notification.
    # Do not log contact details or leave them in test chats.
    await message.answer(f"Thanks, {data['name']}! The demo form is complete; no data was saved.")


@router.message(OrderForm.contact)
async def reject_non_text_contact(message: Message) -> None:
    await message.answer("Send a sample contact as a text message.")


async def main() -> None:
    token = os.getenv("BOT_TOKEN")
    if not token:
        raise RuntimeError("Set BOT_TOKEN in the environment before starting the bot")

    bot = Bot(token=token)
    dispatcher = Dispatcher()
    dispatcher.include_router(router)

    await dispatcher.start_polling(bot)


if __name__ == "__main__":
    asyncio.run(main())

Open your bot and test /start, both buttons, the two form steps and /cancel. Each callback is acknowledged with callback.answer(): it confirms the click to the Telegram client. If the handler fails before answering, the button may keep showing a loading state.

What matters in this example

Router and filters

Router groups handlers; Dispatcher receives updates and attaches one or more routers. Filters such as CommandStart() and F.data == "catalog" select a handler before its logic runs. Do not put a catch-all message handler before FSM handlers: it can consume the user's form input.

FSM and storage

FSMContext is injected into the handler. A Dispatcher() without a storage argument uses MemoryStorage: states and data disappear when the process stops. If a flow must survive a restart, use an external storage. Aiogram provides RedisStorage, which requires the redis package and a running Redis server. FSM storage is not a replacement for a database of requests or orders.

Personal data

The form is for demonstration. Before collecting real contacts, explain the purpose and retention period, restrict access, and store data securely. Do not print the full user object or message text into ordinary logs.

Middleware without assuming every update has a user

An update is not always a user message. Middleware attached to dispatcher.update must not access event.from_user without checking the event type. To measure message handling, use message middleware and avoid logging its contents:

import logging
from time import perf_counter
from typing import Any, Awaitable, Callable

from aiogram import BaseMiddleware
from aiogram.types import Message

logger = logging.getLogger(__name__)


class MessageTimingMiddleware(BaseMiddleware):
    async def __call__(
        self,
        handler: Callable[[Message, dict[str, Any]], Awaitable[Any]],
        event: Message,
        data: dict[str, Any],
    ) -> Any:
        started = perf_counter()
        try:
            return await handler(event, data)
        finally:
            logger.info(
                "message_handled",
                extra={"duration_ms": round((perf_counter() - started) * 1000)},
            )


router.message.outer_middleware(MessageTimingMiddleware())

Configure Python logging in your entry point if you want to see this message in the console. Middleware must call the next handler; finally also measures failed processing.

Run with systemd

For a long-running process, create a dedicated system user. The code and virtual environment in /opt/mybot must be readable and executable by that user. Make /etc/mybot/mybot.env readable only by root and the service group: owner root:mybot, mode 0640. Do not put the token in the unit file or repository.

sudo useradd --system --user-group --no-create-home --shell /usr/sbin/nologin mybot
sudo install -d -o root -g mybot -m 0750 /etc/mybot
sudo install -o root -g mybot -m 0640 /dev/null /etc/mybot/mybot.env
sudoedit /etc/mybot/mybot.env

Add BOT_TOKEN=... to the environment file and save it. Prepare /opt/mybot and .venv so the mybot group can read files and traverse directories; do not give the service permission to change the code.

# /etc/systemd/system/mybot.service
[Unit]
Description=Aiogram bot
Wants=network-online.target
After=network-online.target

[Service]
Type=simple
User=mybot
Group=mybot
WorkingDirectory=/opt/mybot
EnvironmentFile=/etc/mybot/mybot.env
ExecStart=/opt/mybot/.venv/bin/python /opt/mybot/bot.py
Restart=on-failure
RestartSec=5
NoNewPrivileges=true
PrivateTmp=true

[Install]
WantedBy=multi-user.target
sudo systemctl daemon-reload
sudo systemctl enable --now mybot
sudo systemctl status mybot
sudo journalctl -u mybot -f

Check the logs after startup and send /start to the bot. To update, stop the service, update the code and dependencies in the virtual environment, then start it again. If a token is exposed in Git or logs, revoke it with BotFather and create a new one.

Next steps

Build your first complete project in the free Aiogram 3 workshop. When you need PostgreSQL, payments, Mini Apps, webhooks, Docker and tests, continue with the full Telegram bot course.

This example is pinned to Aiogram 3.31.0. Before upgrading, check the release notes; implementation details are linked in the Router, FSM storage and middleware docs. The practical projects and source links relate to the FazerCards Telegram store case study.

Build your first Telegram bot

The free Aiogram 3 workshop takes you from the first reply to FSM and a VPS deployment.

Start the free workshop

Need a custom Telegram bot?

I build bots, Mini Apps, payments and automation end to end.