Migration FAQ (2.x -> 3.x)

Ця версія містить численні суттєві зміни та архітектурні покращення. Вона допомагає зменшити кількість глобальних змінних у вашому коді, надає корисні механізми для модуляризації вашого коду та дозволяє створювати спільні модулі за допомогою пакетів на PyPI. Крім того, серед інших покращень, він робить проміжне програмне забезпечення (мідлварі) та фільтри більш контрольованими.

На цій сторінці ви можете прочитати про зміни, внесені в останню стабільну версію 2.x.

Небезпека

Most breaking changes on this page fall into two groups: code that fails loudly right after the upgrade (import errors, removed methods) and code that fails silently — it imports and runs, but misbehaves only on specific updates or under specific conditions. The silent group is marked with warnings across this page; pay extra attention to it.

Renames are cheap to migrate: imports and linters catch them within an hour. The dangerous group is changed defaults and implicit contracts — the state and content-type filters implied by v2 handlers, parse_mode=None, model equality, optional lists becoming None — which compile, pass smoke tests, and break only on live behavior.

Примітка

Не соромтеся зробити свій внесок у цю сторінку, якщо ви знайшли щось, про що тут не згадано.

Залежності

  • Залежності, необхідні для i18n, більше не є частиною пакету за замовчуванням. Якщо ваш додаток використовує функціональність перекладу, обов’язково додайте необов’язкову залежність:

    pip install aiogram[i18n]

    Note that the i18n API itself has also been changed, see i18n migration below.

  • aiogram 3.x requires a much newer aiohttp than v2 did (aiohttp >= 3.9 at the time of writing — check aiogram’s project metadata for the current bounds). If your project uses aiohttp directly (for example, for a webhook web application), check your own code against the aiohttp changelog: arguments that were deprecated in older aiohttp versions have been removed (e.g. the loop= argument of aiohttp.web.Application).

  • Redis storage is now based on the redis package (with asyncio support) instead of aioredis.

  • aiogram 3.x is built on pydantic v2. If your project used pydantic v1 for its own models (settings, database schemas), upgrading aiogram is often the moment pydantic v2 first enters the project — and some v1 patterns break silently: for example, Field(..., env="REDIS_URL") is ignored by pydantic v2 (BaseSettings moved to the separate pydantic-settings package), so a config quietly stops reading environment variables. Check your own models against the pydantic v1 -> v2 migration guide.

  • Recent aiogram releases pin an upper Python bound as well (e.g. >=3.10,<3.15 — check the current project metadata). With Poetry, a caret constraint like python = "^3.11" (which means <4.0) then fails to lock; use an explicitly bounded range such as >=3.11,<3.15.

Bot

Default bot properties (parse_mode and others)

In v2 the global parse mode was configured directly on the Bot instance (Bot(token, parse_mode="HTML")). In v3 all per-bot defaults are grouped into DefaultBotProperties:

# Version 2.x
bot = Bot(token, parse_mode="HTML")
# Version 3.x
from aiogram.client.default import DefaultBotProperties
from aiogram.enums import ParseMode

bot = Bot(
    token=token,
    default=DefaultBotProperties(parse_mode=ParseMode.HTML),
)

DefaultBotProperties also covers other defaults: disable_notification, protect_content, link_preview_is_disabled and other link preview options, etc.

Примітка

In aiogram 3.0 - 3.6 the Bot(parse_mode=...) form was still accepted; it was removed in 3.7 in favor of DefaultBotProperties. If you migrate straight to a recent 3.x release, use DefaultBotProperties only.

Попередження

parse_mode=None in an API call now means the opposite of v2. In v2, method arguments equal to None were dropped from the payload and the bot-level default was applied (payload.setdefault("parse_mode", self.parse_mode)), so parse_mode=None meant «use the bot default». In v3 the «use the default» marker is the Default("parse_mode") sentinel, and an explicit None overrides it — i.e. disables formatting entirely.

Any wrapper that forwards the parameter, like async def send(..., parse_mode=None): await message.answer(text, parse_mode=parse_mode), compiles and silently shows users raw <b> tags. Make the wrapper default to the sentinel instead of None:

from aiogram.client.default import Default

async def send(..., parse_mode: str | Default | None = Default("parse_mode")):
    await message.answer(text, parse_mode=parse_mode)

bot.me is now a method

In v2 me was a property (me = await bot.me), in v3 it is a method (with cached result):

# Version 2.x
me = await bot.me

# Version 3.x
me = await bot.me()

Попередження

This is a silent breakage: await bot.me in v3 fails only at runtime (awaiting a method object), so grep your project for .me usages.

Bot is no longer a context storage

In v2 both Bot and Dispatcher could be used as dictionaries to store arbitrary runtime data (bot["db"] = ..., documented as a feature). In v3:

  • Dispatcher still supports this via dispatcher.workflow_data (dp["key"] = value still works), and all values stored there are automatically injected into handlers, filters, and middlewares as keyword arguments by name.

  • Bot is no longer a data storage of any kind.

# Version 2.x
bot["db"] = db
dp["config"] = config

# Version 3.x
dp["db"] = db          # or Dispatcher(db=db, config=config)
dp["config"] = config

@router.message(Command("info"))
async def handler(message: Message, db: Database, config: Config) -> None:
    # values from workflow_data are injected by argument name
    ...

If you stored data on the Bot instance because multiple bots shared one dispatcher, move that data to a middleware or derive it from the bot argument (e.g. keyed by bot.id).

Диспетчер

  • Клас Dispatcher більше не приймає екземпляр Bot у своєму ініціалізаторі. Замість цього екземпляр Bot слід передавати диспетчеру тільки для запуску полінгу або обробки подій з вебхуків. Такий підхід також дозволяє використовувати декілька екземплярів бота одночасно («мультибот»).

  • Клас Dispatcher тепер можна розширити ще одним об’єктом на кшталт диспетчера з назвою Router (Детальніше »).

  • Видалено суфікс _handler з усіх декораторів обробників подій та методів реєстрації. (Детальніше »)

  • The Executor has been entirely removed; you can now use the Dispatcher directly to start poll the API or handle webhooks from it.

  • Throttling (dp.throttle, Throttled, the rate_limit pattern) has been completely removed; see the Throttling section for the replacement recipe based on middlewares and flags.

  • Вилучено глобальні контекстні змінні з типів API, об’єктів Bot та Dispatcher, Відтепер, якщо ви хочете отримати доступ до поточного екземпляру бота в обробниках або фільтрах, ви повинні приймати аргумент bot: Bot і використовувати його замість Bot.get_current(). У проміжному програмному забезпеченні (middleware) доступ до нього можна отримати через data["bot"].

  • To skip pending updates, you should now call the DeleteWebhook method directly, rather than passing skip_updates=True to the start polling method.

  • To feed updates to the Dispatcher, instead of method process_update(), you should use method feed_update(). (Read more »)

Background handler execution (run_task) is removed

The v2 options Dispatcher(run_tasks_by_default=True) and @dp.message_handler(run_task=True), which executed handlers in background tasks, were removed without a direct equivalent.

In v3, each update is already processed in its own task during polling (start_polling(handle_as_tasks=True) is the default), so slow handlers do not block other users. If you still need fire-and-forget behavior inside a handler, schedule the work explicitly:

import asyncio

background_tasks = set()

@router.message(Command("slow"))
async def handler(message: Message) -> None:
    task = asyncio.create_task(do_slow_work(message.chat.id))
    background_tasks.add(task)  # keep a reference to avoid premature garbage collection
    task.add_done_callback(background_tasks.discard)

Note that an exception raised inside a detached task never reaches the aiogram error handlers — the update is already considered processed by then. Keep a reference to the task and handle (or at least log) errors inside it yourself.

AllowedUpdates helper is removed

The v2 helper aiogram.types.AllowedUpdates no longer exists. In v3 pass plain strings or aiogram.enums.update_type.UpdateType members, or resolve the list from your registered handlers via resolve_used_update_types():

# Version 2.x
executor.start_polling(dp, allowed_updates=types.AllowedUpdates.MESSAGE)
# Version 3.x
from aiogram.enums import UpdateType

await dp.start_polling(bot, allowed_updates=[UpdateType.MESSAGE])
# or let aiogram compute it from your handlers:
await dp.start_polling(bot, allowed_updates=dp.resolve_used_update_types())

Попередження

When allowed_updates is not passed to start_polling, aiogram 3 automatically requests only the update types for which you have handlers (it calls resolve_used_update_types() for you). This differs from v2, where the bot received the server-default set of updates. If some of your updates are consumed only by middlewares or outside the dispatcher, pass allowed_updates explicitly.

Фільтрація подій

  • Фільтри за ключовими словами більше не можна використовувати; використовуйте фільтри явно. (Детальніше »)

  • У зв’язку з вилученням keyword фільтрів, всі раніше ввімкнені за замовчуванням фільтри (такі як state і content_type) тепер вимкнено. Якщо ви бажаєте їх використовувати, ви повинні вказати їх явно. Наприклад, замість @dp.message_handler(content_types=ContentType.PHOTO) слід використовувати @router.message(F.photo).

  • Most common filters have been replaced with the «magic filter.» (Read more »)

  • Додано можливість реєстрації глобальних фільтрів для кожного роутера, що допомагає зменшити повторення коду і полегшує контроль призначення кожного роутера.

Попередження

A bare v2 handler had two implicit filters; a bare v3 handler has none.

@dp.message_handler() without arguments implicitly meant content_types=ContentType.TEXT and state=None (outside of any FSM state). @router.message() means neither — it receives every content type in every state. Consequences of a straight-across migration:

  • stickers and photos land in «text» handlers, and message.text.lower() raises AttributeError: 'NoneType' object has no attribute 'lower' on the first non-text message;

  • handlers fire in the middle of FSM dialogs where v2 silently skipped them (see Default state filter behavior is inverted);

  • Command() now also matches commands in media captions — in v2 the implicit TEXT filter masked that.

Add the content filter explicitly:

# Version 2.x
@dp.message_handler()
async def handler(message: types.Message):
    print(message.text.lower())
# Version 3.x
@router.message(F.text)
async def handler(message: Message) -> None:
    print(message.text.lower())

Use the matching magic filter for other content types (F.photo, F.document, F.sticker, …), or keep the handler unfiltered on purpose and guard every field access.

The chat_type filter

The commonly used v2 keyword filter chat_type= should be replaced with a magic filter. Note that the path to the chat differs between event types:

# Version 2.x
@dp.message_handler(chat_type=types.ChatType.PRIVATE)
@dp.callback_query_handler(chat_type=[types.ChatType.GROUP, types.ChatType.SUPERGROUP])
# Version 3.x
from aiogram import F
from aiogram.enums import ChatType

@router.message(F.chat.type == ChatType.PRIVATE)
@router.callback_query(F.message.chat.type.in_({ChatType.GROUP, ChatType.SUPERGROUP}))

Попередження

For a message the chat is F.chat, but for a callback query the chat lives on the attached message: F.message.chat. A copied-over F.chat.type filter on a callback query handler compiles and simply never matches — the handler goes silently dead.

The Text filter

The v2 Text filter has no equivalent in v3: it was dropped during the 3.0 beta cycle (in 3.0.0b8), before the first stable 3.0 release, so it is not available in any stable 3.x version. Use the magic filter:

# Version 2.x
@dp.message_handler(text="hello")
@dp.message_handler(text_startswith="foo")
# Version 3.x
@router.message(F.text == "hello")
@router.message(F.text.startswith("foo"))
# also useful: F.text.in_({...}), F.text.contains(...),
# case-insensitive: F.text.casefold() == "hello"

Примітка

Don’t confuse the removed filter with aiogram.utils.formatting.Text — that one is a text formatting tool, not a filter.

Command arguments (message.get_args)

The v2 method Message.get_args() is removed. The Command filter now passes a CommandObject into the handler:

# Version 2.x
@dp.message_handler(commands=["start"])
async def handler(message: types.Message):
    args = message.get_args()  # "" if no args
# Version 3.x
from aiogram.filters import Command, CommandObject

@router.message(Command("start"))
async def handler(message: Message, command: CommandObject) -> None:
    args = command.args  # None if no args

Note that command.args is None (not an empty string) when the command has no arguments.

Other removed Message helpers

Message.is_command(), Message.get_command() and Message.is_forward() were removed without replacement. Inside the dispatcher, use the Command filter and CommandObject instead. If you inspect updates outside the dispatcher (custom routing, raw update processing), reimplement the checks manually:

def is_command(message: Message) -> bool:
    # v2-parity: media captions count too, and no entity is required
    text = message.text or message.caption
    return bool(text and text.startswith("/"))

def is_forward(message: Message) -> bool:
    return message.forward_origin is not None

Mind the exact v2 semantics when writing the replacement:

  • v2 is_command() was literally «text or caption starts with /», and no bot_command entity was required. A stricter entity-based check narrows behavior (captions stop counting, and commands that Telegram does not mark with an entity — e.g. non-ASCII ones like /пинг — stop matching); aiogram’s own Command filter also parses text/caption rather than entities.

  • v2 is_forward() was bool(message.forward_date). The field still exists on the v3 model but is deprecated and never populated since Bot API 7.0, so that check silently becomes always-false — use message.forward_origin is not None (see Forwarded messages: forward_from is dead).

Default state filter behavior is inverted

Попередження

This is one of the most dangerous silent changes in v3.

  • In v2 a handler without a state filter ran only in the default (no) state; to run in any state you had to pass state="*".

  • In v3 a handler without a StateFilter (from aiogram.filters import StateFilter) runs in any state.

After a naive migration, handlers start to trigger in situations where they were silently skipped before — e.g. a menu handler now fires in the middle of an FSM dialog.

Migration rules:

  • v2 state="*" -> v3: no state filter at all.

  • v2 without state -> v3: StateFilter(None) if you want to keep the old behavior.

  • v2 state=MyGroup.my_state -> v3: StateFilter(MyGroup.my_state) (or pass the state directly as a filter: @router.message(MyGroup.my_state)).

Bot API

  • Всі методи API тепер є класами з валідацією, реалізованими через pydantic. Ці виклики API також доступні як методи в класі Bot.

  • More pre-defined Enums have been added and moved to the aiogram.enums sub-package. For example, the chat type enum is now aiogram.enums.chat_type.ChatType instead of aiogram.types.chat.ChatType.

  • Клієнтська сесія HTTP була відокремлена в контейнер, який можна повторно використовувати для різних екземплярів бота в додатку.

  • API Exceptions are no longer classified by specific messages, as Telegram has no documented error codes. However, all errors are classified by HTTP status codes, and for each method, only one type of error can be associated with a given code. Therefore, in most cases, you should check only the error type (by status code) without inspecting the error message. More details can be found in the exceptions section ».

Renamed methods

v2 kept some pre-Bot API 5.3 method names that are gone in v3:

All other methods follow the current Bot API names — when in doubt, check the method list in the API reference rather than assuming the v2 name still exists.

Renames and removals inside Telegram types

The same applies to shortcuts and fields of the types themselves:

  • chat.kick(...) -> aiogram.types.chat.Chat.ban() (aiogram.types.chat.Chat.unban() kept its name).

  • ChatPermissions.can_send_media_messages no longer exists: Bot API 6.5 split it into the granular can_send_audios, can_send_documents, can_send_photos, can_send_videos, can_send_video_notes and can_send_voice_notes flags.

Попередження

Telegram types in v3 accept extra fields, so ChatPermissions(can_send_media_messages=True) does not raise a validation error. The unknown field is sent to Telegram, ignored there, and the permissions you meant to grant are silently not applied. Replace it with the granular flags:

# Version 2.x
permissions = types.ChatPermissions(can_send_media_messages=True)
# Version 3.x
permissions = ChatPermissions(
    can_send_audios=True,
    can_send_documents=True,
    can_send_photos=True,
    can_send_videos=True,
    can_send_video_notes=True,
    can_send_voice_notes=True,
)

Constructors of types and methods are keyword-only

All Telegram types and API methods are pydantic models now, so positional arguments are not accepted:

# Version 2.x
button = InlineKeyboardButton("Press me", callback_data="click")
command = BotCommand("help", "Show help")
# Version 3.x
button = InlineKeyboardButton(text="Press me", callback_data="click")
command = BotCommand(command="help", description="Show help")

Positional construction fails with a validation error at runtime, so this cannot be caught by import checks — grep for positional usages of Telegram types while migrating.

Positional arguments of API calls now bind to different parameters

Попередження

This is the quiet counterpart of the rule above. Calls of Bot API methods and of type shortcuts still accept positional arguments — and that is exactly the problem. New Bot API parameters were inserted into the middle of existing signatures, so v2-era positional calls compile, but the values land in the wrong parameters:

  • The second parameter of bot.edit_message_text() is now business_connection_id (it was chat_id in v2), so bot.edit_message_text(text, chat_id, message_id) misbinds every argument after the first.

  • The second parameter of aiogram.types.message.Message.answer() is now direct_messages_topic_id (it was parse_mode in v2), so message.answer(text, parse_mode) passes the parse mode as a topic id.

How this fails depends on the values, and neither way is caught before the code path actually runs:

  • Type-incompatible bindings (an int chat id into the str | None business connection id, "HTML" into an int | None topic id) raise ValidationError — loud, but only at runtime, on the affected call.

  • Type-compatible bindings pass silently: an "@username" chat id is a perfectly valid str for business_connection_id, and a wrapper forwarding parse_mode=None binds direct_messages_topic_id=None — the message is sent, just with formatting silently dropped.

Pass all Bot API method arguments as keywords, and audit every positional call while migrating:

# Version 2.x
await bot.edit_message_text("New text", chat_id, message_id)
await message.answer("<b>Hi</b>", "HTML")
# Version 3.x
await bot.edit_message_text(text="New text", chat_id=chat_id, message_id=message_id)
await message.answer(text="<b>Hi</b>", parse_mode="HTML")

Telegram objects behavior

Incoming objects are immutable (frozen)

Telegram types in v3 are pydantic models, and the types you receive from Telegram are frozen: Message, CallbackQuery, User, Chat and every other subclass of aiogram.types.base.TelegramObject. Any code that mutated such objects in-place (most commonly tests) must be updated:

# Version 2.x
message.text = "edited"
# Version 3.x
new_message = message.model_copy(update={"text": "edited"})

The «input» types you build yourself and send to Telegram remain mutable — they inherit aiogram.types.base.MutableTelegramObject (frozen=False): InlineKeyboardButton, KeyboardButton, the reply markup types, BotCommand, MessageEntity, ChatPermissions, the InputMedia* family and others. Assigning to their fields still works.

Optional list fields are None, not []

Попередження

In v2, optional array fields defaulted to empty lists. In v3 they are None when absent, matching the Bot API. This is not specific to Message — it holds for every optional array field on every type, and there are dozens of them across the API.

Code like for entity in message.entities: passes review and works on most messages, then raises TypeError on the first message without entities. The same applies to API responses, not only incoming updates: e.g. WebhookInfo.allowed_updates is None when unrestricted, so set(webhook_info.allowed_updates) crashes right at startup. Always default the value:

for entity in message.entities or []:
    ...

allowed = set(webhook_info.allowed_updates or [])

Unix timestamps became datetime

Попередження

v2 handled date fields inconsistently, per field. Some were parsed into datetime (Message.date, Message.edit_date, ChatMember.until_date were declared as fields.DateTimeField()), others stayed raw Unix integers (WebhookInfo.last_error_date, PassportFile.file_date were plain fields.Field() typed as base.Integer). In WebhookInfo the two kinds sat next to each other: last_error_date was an int, while last_synchronization_error_date right below it was a datetime.

In v3 every date field uses the same annotated type, aiogram.types.custom.DateTime, and pydantic parses the incoming Unix timestamp into a timezone-aware datetime in UTC (message.date.tzinfo is UTC). Serialization back to the Bot API converts it to an int again, so you never build timestamps by hand.

Every v2-era manual conversion therefore breaks with a TypeError (the exact message depends on the Python version), and only when that line actually runs:

# Version 2.x
last_error = datetime.utcfromtimestamp(webhook_info.last_error_date)
# Version 3.x — the field is already a datetime
last_error = webhook_info.last_error_date

# ...and converting back is explicit:
timestamp = int(message.date.timestamp())

Watch for the mirror-image trap: since the values are timezone-aware, comparing one with a naive datetime raises TypeError: can't compare offset-naive and offset-aware datetimes. Use an aware value on the other side of the comparison — e.g. datetime.now(timezone.utc) instead of datetime.utcnow().

Objects are compared by value, not by id

Попередження

This is a silent breakage with no error message at all.

In v2, User.__hash__ returned self.id and TelegramObject.__eq__ compared the class plus that hash, so two User objects describing the same person were equal regardless of which fields were filled in.

In v3 there is no custom __eq__ / __hash__: pydantic compares all fields, and frozen models hash over the field values. Different API responses fill in different subsets of fields — the from_user of an update, an entry of get_chat_administrators() and the result of get_me() are all different objects for the same user — so comparisons that used to match now silently stop matching:

# Version 2.x — compared by user id
if user == await bot.me:
    ...
admin_users = [m.user for m in await bot.get_chat_administrators(chat_id)]
if user in admin_users:  # worked in v2: Users matched by id
    ...
# Version 3.x — compare ids explicitly
me = await bot.me()
if user.id == me.id:
    ...

admins = await bot.get_chat_administrators(chat_id)
if user.id in {admin.user.id for admin in admins}:
    ...

The same applies to deduplication: set[User] and dict[User, ...] no longer collapse duplicates of the same user — build the set over user.id instead.

The .bot attribute and shortcut methods

In v2 shortcuts like message.answer(...) resolved the bot instance from a global context. In v3 the bot instance is attached to every object during deserialization of an update, through the pydantic validation context.

Objects received in handlers work as before: await message.answer(...) is fine.

Попередження

Objects you create manually (or deserialize yourself) have bot=None, and their shortcut methods fail at call time. Bind the bot explicitly:

message = Message.model_validate(data, context={"bot": bot})
# or for an existing object:
message = message.as_(bot)

For background tasks and code far from handlers, pass the bot instance explicitly instead of relying on shortcuts of stored objects.

Forwarded messages: forward_from is dead

Попередження

The v2-era fields forward_date, forward_from, forward_from_chat, forward_from_message_id still exist on Message (deprecated), but since Bot API 7.0 Telegram no longer sends them — so migrated code that reads them compiles and silently sees None. Use forward_origin instead:

from aiogram.types import MessageOriginUser

if isinstance(message.forward_origin, MessageOriginUser):
    original_sender = message.forward_origin.sender_user

Note that this cuts both ways: in v2 these checks had been silently returning False/None since Bot API 7.0, disabling every code branch behind them — and an honest migration to forward_origin resurrects those branches, a production behavior change no linter or test will flag. Before migrating, audit which v2 field checks (forward_*, via_bot, anything removed by newer Bot API versions) are already always false on your real traffic: each one is a branch that will either come back to life or should be consciously removed.

CallbackQuery.message can be inaccessible

Попередження

In v2, callback_query.message was a regular Message (or None), and pressing a button attached to a message older than 48 hours produced a MESSAGE_ID_INVALID API error that your error handlers caught.

In v3 the field is Message | InaccessibleMessage | None. For old or deleted messages Telegram sends InaccessibleMessage, which carries only chat/message_id/date (no message content fields at all) and has no editing or deleting shortcuts (edit_text, edit_reply_markup, edit_caption, delete, forward, pin, …) — callback_query.message.edit_text(...) raises AttributeError in Python before any API call is made, so the whole v2-era «expired button» handling silently stops working. (answer_*/reply_* send shortcuts do exist on InaccessibleMessage since aiogram 3.13.) Check the type first:

from aiogram.types import Message

if isinstance(callback_query.message, Message):
    await callback_query.message.edit_text("...")
else:  # InaccessibleMessage or None
    await callback_query.answer("This button has expired", show_alert=True)

repr() of objects is much larger now

In v2, repr(message) was compact; in v3, pydantic renders every field, including the ~150 None-valued optional ones. Log statements like log.debug("Processing %r", message) multiply log volume by orders of magnitude after migration — on busy bots this has a real storage/latency cost. Log selected fields (e.g. message.message_id, message.chat.id) instead of whole objects on hot paths.

Перетворення об’єктів Telegram (у словник, у json, з json)

  • Methods TelegramObject.to_object(), TelegramObject.as_json() and TelegramObject.to_python() have been removed due to the use of pydantic models.

  • TelegramObject.to_object() слід замінити на TelegramObject.model_validate() (Детальніше)

  • <TelegramObject>.as_json() should be replaced by json.dumps(deserialize_telegram_object_to_python(<TelegramObject>))

  • <TelegramObject>.to_python() should be replaced by aiogram.utils.serialization.deserialize_telegram_object_to_python()

Попередження

The obvious pydantic replacement — bare model_dump() — is not equivalent to v2 to_python() and silently changes the data shape: it includes every unset optional field as None (dozens of keys even for small objects, ~150 for a Message) and returns datetime/enum values as Python objects rather than JSON primitives. Code that dumps objects into MongoDB or an external API gets bloated documents and a different wire format without a single error — e.g. a Mongo $set built from model_dump() overwrites previously stored values with None. Use deserialize_telegram_object_to_python(), or at least model_dump(mode="json", exclude_none=True).

# Version 2.x
message_dict = message.to_python()
message_json = message.as_json()
# Version 3.x
import json

from aiogram.utils.serialization import deserialize_telegram_object_to_python

message_dict = deserialize_telegram_object_to_python(message)
message_json = json.dumps(message_dict)

Інструменти ChatMember

Примітка

The tools below (ChatMemberAdapter, ADMINS, MEMBERS) were added in aiogram 3.9; on earlier 3.x releases, use isinstance() checks against the concrete ChatMember* classes directly.

  • Тепер aiogram.types.chat_member.ChatMember більше не містить інструментів для вирішення об’єкта з відповідним статусом.

    # Version 2.x
    from aiogram.types import ChatMember
    
    chat_member = ChatMember.resolve(**dict_data)
    
    # Version 3.x
    from aiogram.utils.chat_member import ChatMemberAdapter
    
    chat_member = ChatMemberAdapter.validate_python(dict_data)
    
  • Відтепер aiogram.types.chat_member.ChatMember та всі його дочірні класи більше не містять методів для перевірки належності до певних логічних груп. Замість цього ви можете використовувати попередньо визначені групи або створювати такі групи самостійно та перевіряти їх входження за допомогою функції isinstance()

    # Version 2.x
    if chat_member.is_chat_admin():
        print("ChatMember is chat admin")
    
    if chat_member.is_chat_member():
        print("ChatMember is in the chat")
    
    # Version 3.x
    from aiogram.utils.chat_member import ADMINS, MEMBERS
    
    if isinstance(chat_member, ADMINS):
        print("ChatMember is chat admin")
    
    if isinstance(chat_member, MEMBERS):
        print("ChatMember is in the chat")
    

    Примітка

    Також ви можете самостійно створити групу, подібну до ADMINS, яка відповідає логіці вашого застосунку.

    Наприклад, ви можете створити групу PUNISHED та включити туди заблокованих та обмежених учасників!

Exceptions

Mapping (v2 -> v3)

All v3 exception classes live in aiogram.exceptions.

The v2 Unauthorized family covered two different HTTP statuses, and v3 keeps them apart:

  • an invalid or revoked bot token (HTTP 401) -> TelegramUnauthorizedError

  • Forbidden: ... responses (HTTP 403) — the bot was blocked by the user, kicked from the chat, or the user was deactivated, i.e. v2 BotBlocked, BotKicked, UserDeactivated, CantInitiateConversation -> TelegramForbiddenError

Попередження

Attributes were renamed too, not only the classes. The most important one: v2 RetryAfter.timeout is now TelegramRetryAfter.retry_after. An except block migrated only by class name compiles fine and crashes with AttributeError only under flood limits:

# Version 2.x
except exceptions.RetryAfter as e:
    await asyncio.sleep(e.timeout)
# Version 3.x
except TelegramRetryAfter as e:
    await asyncio.sleep(e.retry_after)

(migrate_to_chat_id kept its name from v2 MigrateToChat.)

Two v3 classes have no v2 counterpart at all:

  • TelegramEntityTooLarge — HTTP 413, raised for file uploads that exceed the server limit

  • ClientDecodeError — raised when the response body cannot be decoded; carries original (the underlying exception) and data (the raw response body)

Попередження

except TelegramAPIError is no longer a catch-all. ClientDecodeError is not a subclass of TelegramAPIError — they only share the common base AiogramError — so a v2-style catch-all migrated as except TelegramAPIError silently stops covering response-parsing errors. If you need «catch everything aiogram can raise», catch AiogramError.

This bites hardest with self-hosted Bot API servers — see Telegram API Server.

Exceptions removed in v3 (from v2)

v2 shipped around a hundred fine-grained exception classes that were detected by matching the error text (MessageNotModified, ChatNotFound, …). None of them exist in v3: exceptions are classified only by the HTTP status code of the response, because Telegram does not document stable error codes.

The v2 class hierarchy tells you which v3 class replaces each name — everything that derived from v2 BadRequest is HTTP 400, while the subclasses of v2 Unauthorized (BotBlocked, BotKicked, …) are delivered by Telegram as Forbidden: ... with HTTP 403 (a bare Unauthorized — an invalid token — is HTTP 401, see the split above):

Примітка

Because the classification is by status code only, several unrelated v2 names collapse into a single v3 class. If you really need to distinguish a specific cause inside TelegramBadRequest, match on the error text:

from aiogram.exceptions import TelegramBadRequest

try:
    await message.edit_text("Same text")
except TelegramBadRequest as e:
    if "message is not modified" not in e.message:
        raise

Keep in mind that these texts are not part of the documented Bot API and may change, so use the narrowest check you can and always re-raise what you did not expect.

Error handlers

The signature and registration of error handlers changed completely:

# Version 2.x
@dp.errors_handler(exception=MyCustomError)
async def my_error_handler(update: types.Update, exception: Exception):
    ...
    return True  # mark error as handled, stop propagation
# Version 3.x
from aiogram import F
from aiogram.filters import ExceptionTypeFilter
from aiogram.types import ErrorEvent, Message

@router.error(ExceptionTypeFilter(MyCustomError), F.update.message.as_("message"))
async def my_error_handler(event: ErrorEvent, message: Message) -> None:
    await message.answer("Oops, something went wrong!")

Key differences:

  • The handler receives a single ErrorEvent with event.update and event.exception, instead of two arguments.

  • Filtering by exception type is done with ExceptionTypeFilter instead of the exception= keyword.

  • v2 semantics «return True to stop other error handlers» is gone. Error handlers now behave like any other observer: the first handler whose filters match handles the error, and propagation stops — no return value is needed.

  • Errors unhandled by any error handler are logged by the aiogram.event logger.

Read more: Error handling docs.

Проміжне ПО (Middlewares)

  • Проміжне програмне забезпечення тепер може керувати контекстом виконання, наприклад, за допомогою менеджерів контексту. (Детальніше »)

  • Всі контекстні дані тепер наскрізно використовуються між проміжним програмним забезпеченням, фільтрами та обробниками. Наприклад, тепер ви можете легко передати деякі дані в контекст у проміжному програмному забезпеченні і отримати їх у шарі фільтрів так само, як і в обробниках через аргументи ключових слів.

  • Додано механізм з назвою flags, який допомагає налаштовувати поведінку обробника у поєднанні з проміжним програмним забезпеченням. (Детальніше про »)

  • aiogram.contrib.middlewares.logging.LoggingMiddleware is removed together with the whole aiogram.contrib package. Use standard logging configuration for aiogram loggers (aiogram.event and others), or write a trivial middleware.

Throttling

The entire v2 throttling API was removed with no built-in replacement:

  • dp.throttle(), dp.check_key(), dp.release_key()

  • the Throttled exception

  • the rate_limit decorator and the ThrottlingMiddleware recipe from the official v2 documentation

  • CancelHandler / current_handler used by that recipe (in v3 a middleware simply returns without calling handler(...) to drop an event)

  • the «bucket» API of FSM storages (get_bucket / set_bucket) — v3 storages keep only state and data

The v3 approach is an inner middleware, optionally configured per-handler with flags:

from collections.abc import Awaitable, Callable
from time import monotonic
from typing import Any

from aiogram import BaseMiddleware
from aiogram.dispatcher.flags import get_flag
from aiogram.types import Message


class ThrottlingMiddleware(BaseMiddleware):
    def __init__(self, default_rate: float = 0.5) -> None:
        self.default_rate = default_rate
        # scoped per (user, handler): one handler's throttle must not
        # suppress unrelated handlers, like v2's per-handler rate_limit
        self.last_call: dict[tuple[int, Any], float] = {}

    async def __call__(
        self,
        handler: Callable[[Message, dict[str, Any]], Awaitable[Any]],
        event: Message,
        data: dict[str, Any],
    ) -> Any:
        if event.from_user is None:  # channel posts have no sender
            return await handler(event, data)

        rate = self.default_rate
        flag = get_flag(data, "rate_limit")
        if flag is not None:
            rate = flag.get("rate", self.default_rate)

        key = (event.from_user.id, data["handler"].callback)
        now = monotonic()
        last = self.last_call.get(key)
        if last is not None and now - last < rate:
            return None  # drop the event
        self.last_call[key] = now
        return await handler(event, data)
from aiogram import flags

router.message.middleware(ThrottlingMiddleware())


@router.message(Command("expensive"))
@flags.rate_limit(rate=5.0)
async def handler(message: Message) -> None:
    ...

Попередження

Register this as an inner middleware, exactly as shown above (router.message.middleware(...)), not as an outer one (router.message.outer_middleware(...)). The resolved handler and its flags only exist between the outer and the inner middleware layers: in an outer middleware data["handler"] is not set at all (the example above would raise KeyError) and get_flag(data, "rate_limit") always returns None.

Примітка

The recipe is intentionally simple and is not a semantic clone of v2 dp.throttle(): v2 did not throttle the first call, keyed buckets by the rate_limit key rather than by handler, and stored buckets in the FSM storage — so limits were shared between replicas of the bot. If you need cross-replica throttling, keep the timestamps in your storage (Redis) instead of a process-local dict.

Примітка

The example above keeps timestamps in an unbounded in-memory dict to stay short. In production, use a TTL cache or your storage backend, and answer the user («too many requests») instead of dropping silently if that fits your UX.

Розмітка клавіатури

Дані зворотного виклику

  • Фабрику даних зворотного виклику тепер строго типізовано за допомогою моделей pydantic. (Детальніше »)

Скінченний автомат

  • State filters are no longer applied implicitly — see Default state filter behavior is inverted.

  • Додано можливість змінювати стратегію FSM. Наприклад, якщо ви хочете контролювати стан для кожного користувача на основі топіків чату, а не користувача в чаті, ви можете вказати це в Диспетчері.

  • Now aiogram.fsm.state.State and aiogram.fsm.state.StatesGroup don’t have helper methods like .set(), .next(), etc. Instead, you should set states by passing them directly to aiogram.fsm.context.FSMContext (Read more »)

  • Проксі стану є застарілим; вам слід оновити дані стану, викликавши state.set_data(...) та state.get_data() відповідно.

  • Storages moved from aiogram.contrib.fsm_storage to aiogram.fsm.storage:

Storage keys and migrating live states (Redis)

Попередження

If the key format of your v3 storage does not match the keys your v2 bot wrote, all live user states are silently «lost» after the deploy: the bot simply reads empty state for everyone. Verify key compatibility before switching over.

Note also that a topic-aware FSM strategy inserts an extra thread_id segment into the key (see below), so check the pattern against real keys taken from your traffic, not only against the shape shown here.

In v3 the storage key layout is controlled by a KeyBuilder. The default DefaultKeyBuilder produces:

<prefix>:<bot_id?>:<business_connection_id?>:<chat_id>:<thread_id?>:<user_id>:<destiny?>:<field>

The segments marked with ? are conditional:

  • bot_id — only with with_bot_id=True (off by default)

  • business_connection_id — only with with_business_connection_id=True and when the key actually carries one

  • thread_id — whenever the key carries one, i.e. with the topic-aware FSM strategies; this segment is not controlled by a builder option

  • destiny — only with with_destiny=True; without it, a non-default destiny raises ValueError instead of being silently dropped

  • fieldstate, data or lock

With the default builder options and the default FSM strategy this reduces to:

fsm:<chat_id>:<user_id>:state
fsm:<chat_id>:<user_id>:data

which matches the default v2 RedisStorage2 layout (fsm:<chat>:<user>:state). But if your v2 setup used a custom prefix, or the older v2 RedisStorage (v1-style), the formats differ. Many v2 layouts can be reproduced by configuring the builder:

from aiogram.fsm.storage.base import DefaultKeyBuilder
from aiogram.fsm.storage.redis import RedisStorage

storage = RedisStorage.from_url(
    "redis://localhost:6379/0",
    key_builder=DefaultKeyBuilder(
        prefix="my_fsm_key",  # your v2 prefix, default "fsm"
        with_bot_id=False,    # v2 keys never contained bot id
    ),
)

Notes:

  • with_bot_id=True is recommended for new projects and required for multibot setups, but it changes the key format — don’t enable it while you still need to read v2-era keys.

  • If you switch the dispatcher to a topic-aware FSM strategy, the keys grow a thread_id segment and stop matching your v2 keys — that is a separate migration, not a drop-in change.

  • A KeyBuilder only controls key names, not the record layout. The old (non-2) v2 RedisStorage stored one JSON blob ({"state": ..., "data": ..., "bucket": ...}) per fsm:<chat>:<user> key — no key builder can make v3 read that; the only path is a one-off script that splits each record into the v3 ...:state / ...:data keys.

  • With v2 RedisStorage2, there was a third record type next to ...:state / ...:data — the ...:bucket keys of the removed throttling API; v3 never reads them, so they can be deleted.

  • Check with redis-cli --scan --pattern 'fsm:*' (or your prefix) that the v3 bot reads and writes exactly the same keys as the v2 bot did.

Надсилання файлів

In v2 you could pass an IO object directly to the API method or wrap it in the InputFile class. In v3, InputFile is abstract and cannot be instantiated or receive raw IO objects — use one of the concrete classes:

# Version 2.x
await bot.send_photo(chat_id, photo=open("photo.png", "rb"))
# or
await bot.send_photo(chat_id, photo=types.InputFile("photo.png"))
# Version 3.x
from aiogram.types import FSInputFile

await bot.send_photo(chat_id, photo=FSInputFile("photo.png"))

(Read more »)

Utilities and contrib

The whole aiogram.contrib package is removed. Where its contents went:

  • aiogram.contrib.fsm_storage.* -> aiogram.fsm.storage.* (see Finite State machine)

  • aiogram.contrib.middlewares.logging.LoggingMiddleware -> removed, use standard logging (see Middlewares)

  • aiogram.contrib.middlewares.i18n.I18nMiddleware -> aiogram.utils.i18n (see below and Translation)

Other utility changes:

  • aiogram.utils.json (JSON library selection) is removed without replacement; aiogram handles serialization internally.

  • aiogram.utils.mixins and ContextInstanceMixin still exist and custom classes built on them migrate unchanged; what was removed is the built-in context on Bot, Dispatcher and Telegram types (see Dispatcher).

  • types.ChatActions helpers are removed. Use the aiogram.enums.chat_action.ChatAction enum with an explicit call, or the ChatActionSender helper:

    # Version 2.x
    await types.ChatActions.typing()
    
    # Version 3.x
    from aiogram.enums import ChatAction
    
    await bot.send_chat_action(chat_id=message.chat.id, action=ChatAction.TYPING)
    

    To keep the action alive for the duration of a long operation, use the context manager:

    from aiogram.utils.chat_action import ChatActionSender
    
    async with ChatActionSender.typing(bot=bot, chat_id=message.chat.id):
        await long_operation()
    

    The same thing can be done per handler with ChatActionMiddleware and the @flags.chat_action(...) decorator, following the same flags mechanism shown in the Throttling section.

I18n

The i18n machinery moved from aiogram.contrib.middlewares.i18n to aiogram.utils.i18n and the API changed completely — the v2 I18nMiddleware with its trigger/gettext methods is replaced by the aiogram.utils.i18n.I18n core class plus a set of middlewares. The canonical reference for the v3 API is Translation; this section only covers what changes when you come from v2.

# Version 2.x
from aiogram.contrib.middlewares.i18n import I18nMiddleware

i18n = I18nMiddleware("mybot", LOCALES_DIR)
dp.middleware.setup(i18n)
_ = i18n.gettext
# Version 3.x
from aiogram.utils.i18n import I18n, SimpleI18nMiddleware
from aiogram.utils.i18n import gettext as _

i18n = I18n(path="locales", default_locale="en", domain="mybot")
SimpleI18nMiddleware(i18n).setup(dp)

Available middlewares:

Lazy translations are available via aiogram.utils.i18n.lazy_gettext.

Примітка

I18n scans and loads locales in its constructor. A .po file without a compiled .mo in the configured domain raises RuntimeError immediately at startup (often at import time) — in v2 the same problem surfaced later. Make sure compiling catalogs (pybabel compile -d locales -D mybot) is part of your build/deploy.

Вебхук

Replying into the webhook response

The v2 helpers for answering directly in the webhook HTTP response — aiogram.dispatcher.webhook.SendMessage, DeleteMessage, etc. with .get_response() — were removed.

  • If you serve the webhook with aiogram’s own aiohttp application (SimpleRequestHandler), return a method object from the handler and it is serialized into the webhook response (including file uploads) — but only with SimpleRequestHandler(..., handle_in_background=False). The default is handle_in_background=True, which answers Telegram with an empty response immediately and sends any returned method as a separate Bot API request instead.

  • If you plug aiogram into a third-party web framework (FastAPI, Sanic, …), the direct equivalent of v2 .get_response() is deserialize_telegram_object_to_python(), which produces the same payload from any method object (the API method name is included by default via include_api_method_name=True):

    # Version 2.x
    from aiogram.dispatcher.webhook import DeleteMessage
    
    return DeleteMessage(chat_id=..., message_id=...).get_response()
    
    # Version 3.x
    from aiogram.methods import DeleteMessage
    from aiogram.utils.serialization import deserialize_telegram_object_to_python
    
    return deserialize_telegram_object_to_python(
        DeleteMessage(chat_id=..., message_id=...),
        include_api_method_name=True,
    )
    

    If your Bot is configured with DefaultBotProperties, pass them too — deserialize_telegram_object_to_python(method, default=bot.default, ...) — otherwise defaults such as parse_mode are silently omitted from the payload. Note that file uploads cannot be answered this way (they require a multipart response body); send them with a regular API call instead.

Сервер Telegram API

  • The server parameter has been moved from the Bot instance to api parameter of the BaseSession.

  • The constant aiogram.bot.api.TELEGRAM_PRODUCTION has been moved to aiogram.client.telegram.PRODUCTION.

  • If you run a self-hosted Bot API server, upgrade it together with aiogram. aiogram 3.x declares fields from recent Bot API versions as required (e.g. ChatMemberRestricted.can_react_to_messages), so responses from a server lagging a few versions behind fail pydantic validation — every affected call raises ClientDecodeError, bypassing except TelegramAPIError handlers entirely (see the Exceptions section).