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.9at 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. theloop=argument ofaiohttp.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 (BaseSettingsmoved to the separatepydantic-settingspackage), 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 likepython = "^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:
Dispatcherstill supports this viadispatcher.workflow_data(dp["key"] = valuestill works), and all values stored there are automatically injected into handlers, filters, and middlewares as keyword arguments by name.Botis 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
Executorhas been entirely removed; you can now use theDispatcherdirectly to start poll the API or handle webhooks from it.Throttling (
dp.throttle,Throttled, therate_limitpattern) 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
DeleteWebhookmethod directly, rather than passingskip_updates=Trueto the start polling method.To feed updates to the
Dispatcher, instead of methodprocess_update(), you should use methodfeed_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()raisesAttributeError: '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 implicitTEXTfilter 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 nobot_commandentity 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 ownCommandfilter also parses text/caption rather than entities.v2
is_forward()wasbool(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 — usemessage.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.ChatTypeinstead ofaiogram.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:
bot.kick_chat_member->bot.ban_chat_member(aiogram.methods.ban_chat_member.BanChatMember)bot.get_chat_members_count->bot.get_chat_member_count(aiogram.methods.get_chat_member_count.GetChatMemberCount)bot.set_sticker_set_thumb->bot.set_sticker_set_thumbnail(aiogram.methods.set_sticker_set_thumbnail.SetStickerSetThumbnail)bot.close_bot->bot.close(aiogram.methods.close.Close, the Bot APIclosemethod; to close the HTTP client session, useawait bot.session.close())bot.download_file_by_id->download(), which accepts both a file id and aFile-like object
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_messagesno longer exists: Bot API 6.5 split it into the granularcan_send_audios,can_send_documents,can_send_photos,can_send_videos,can_send_video_notesandcan_send_voice_notesflags.
Попередження
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 nowbusiness_connection_id(it waschat_idin v2), sobot.edit_message_text(text, chat_id, message_id)misbinds every argument after the first.The second parameter of
aiogram.types.message.Message.answer()is nowdirect_messages_topic_id(it wasparse_modein v2), somessage.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
intchat id into thestr | Nonebusiness connection id,"HTML"into anint | Nonetopic id) raiseValidationError— loud, but only at runtime, on the affected call.Type-compatible bindings pass silently: an
"@username"chat id is a perfectly validstrforbusiness_connection_id, and a wrapper forwardingparse_mode=Nonebindsdirect_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()andTelegramObject.to_python()have been removed due to the use of pydantic models.TelegramObject.to_object()слід замінити наTelegramObject.model_validate()(Детальніше)<TelegramObject>.as_json()should be replaced byjson.dumps(deserialize_telegram_object_to_python(<TelegramObject>))<TelegramObject>.to_python()should be replaced byaiogram.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.
RetryAfter->TelegramRetryAfter(key attribute:retry_after, int — see the warning below)MigrateToChat->TelegramMigrateToChat(key attribute:migrate_to_chat_id, int — same name as in v2)BadRequest(and all of its many v2 subclasses) ->TelegramBadRequestNotFound->TelegramNotFoundConflictError(includingTerminatedByOtherGetUpdates) ->TelegramConflictErrorNetworkError->TelegramNetworkErrorRestartingTelegram->RestartingTelegram, now a subclass ofTelegramServerError(any other HTTP 5xx response raisesTelegramServerErroritself)Unauthorized-> split in two, see below
The v2 Unauthorized family covered two different HTTP statuses, and v3 keeps
them apart:
an invalid or revoked bot token (HTTP 401) ->
TelegramUnauthorizedErrorForbidden: ...responses (HTTP 403) — the bot was blocked by the user, kicked from the chat, or the user was deactivated, i.e. v2BotBlocked,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 limitClientDecodeError— raised when the response body cannot be decoded; carriesoriginal(the underlying exception) anddata(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):
MessageNotModified->TelegramBadRequestMessageToEditNotFound->TelegramBadRequestMessageToDeleteNotFound->TelegramBadRequestMessageCantBeDeleted->TelegramBadRequestMessageIsTooLong->TelegramBadRequestMessageIdentifierNotSpecified->TelegramBadRequestCantParseEntities->TelegramBadRequestChatNotFound->TelegramBadRequestInvalidQueryID->TelegramBadRequestInvalidStickersSet->TelegramBadRequestChatAdminRequired->TelegramBadRequestBotBlocked->TelegramForbiddenErrorBotKicked->TelegramForbiddenErrorUserDeactivated->TelegramForbiddenErrorCantInitiateConversation->TelegramForbiddenErrorTerminatedByOtherGetUpdates->TelegramConflictErrorThrottled-> removed together with the v2 throttling API (see Throttling)
Примітка
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
ErrorEventwithevent.updateandevent.exception, instead of two arguments.Filtering by exception type is done with
ExceptionTypeFilterinstead of theexception=keyword.v2 semantics «return
Trueto 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.eventlogger.
Read more: Error handling docs.
Проміжне ПО (Middlewares)¶
Проміжне програмне забезпечення тепер може керувати контекстом виконання, наприклад, за допомогою менеджерів контексту. (Детальніше »)
Всі контекстні дані тепер наскрізно використовуються між проміжним програмним забезпеченням, фільтрами та обробниками. Наприклад, тепер ви можете легко передати деякі дані в контекст у проміжному програмному забезпеченні і отримати їх у шарі фільтрів так само, як і в обробниках через аргументи ключових слів.
Додано механізм з назвою flags, який допомагає налаштовувати поведінку обробника у поєднанні з проміжним програмним забезпеченням. (Детальніше про »)
aiogram.contrib.middlewares.logging.LoggingMiddlewareis removed together with the wholeaiogram.contribpackage. Use standardloggingconfiguration for aiogram loggers (aiogram.eventand 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
Throttledexceptionthe
rate_limitdecorator and theThrottlingMiddlewarerecipe from the official v2 documentationCancelHandler/current_handlerused by that recipe (in v3 a middleware simply returns without callinghandler(...)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.
Розмітка клавіатури¶
Now
aiogram.types.inline_keyboard_markup.InlineKeyboardMarkupandaiogram.types.reply_keyboard_markup.ReplyKeyboardMarkupno longer have methods for extension, instead you have to use the markup buildersaiogram.utils.keyboard.InlineKeyboardBuilderandaiogram.utils.keyboard.ReplyKeyboardBuilderrespectively (Read more »)Buttons are constructed with keyword-only arguments now, see Constructors of types and methods are keyword-only.
Дані зворотного виклику¶
Фабрику даних зворотного виклику тепер строго типізовано за допомогою моделей pydantic. (Детальніше »)
Скінченний автомат¶
State filters are no longer applied implicitly — see Default state filter behavior is inverted.
Додано можливість змінювати стратегію FSM. Наприклад, якщо ви хочете контролювати стан для кожного користувача на основі топіків чату, а не користувача в чаті, ви можете вказати це в Диспетчері.
Now
aiogram.fsm.state.Stateandaiogram.fsm.state.StatesGroupdon’t have helper methods like.set(),.next(), etc. Instead, you should set states by passing them directly toaiogram.fsm.context.FSMContext(Read more »)Проксі стану є застарілим; вам слід оновити дані стану, викликавши
state.set_data(...)таstate.get_data()відповідно.Storages moved from
aiogram.contrib.fsm_storagetoaiogram.fsm.storage:aiogram.contrib.fsm_storage.memory.MemoryStorage->aiogram.fsm.storage.memory.MemoryStorageaiogram.contrib.fsm_storage.redis.RedisStorage2->aiogram.fsm.storage.redis.RedisStorageaiogram.contrib.fsm_storage.mongo.MongoStorage->aiogram.fsm.storage.mongo.MongoStorage
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 withwith_bot_id=True(off by default)business_connection_id— only withwith_business_connection_id=Trueand when the key actually carries onethread_id— whenever the key carries one, i.e. with the topic-aware FSM strategies; this segment is not controlled by a builder optiondestiny— only withwith_destiny=True; without it, a non-default destiny raisesValueErrorinstead of being silently droppedfield—state,dataorlock
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=Trueis 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_idsegment and stop matching your v2 keys — that is a separate migration, not a drop-in change.A
KeyBuilderonly controls key names, not the record layout. The old (non-2) v2RedisStoragestored one JSON blob ({"state": ..., "data": ..., "bucket": ...}) perfsm:<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/...:datakeys.With v2
RedisStorage2, there was a third record type next to...:state/...:data— the...:bucketkeys 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:
FSInputFile— file on the local filesystemBufferedInputFile—bytesin memoryURLInputFile— file by URL
# 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"))
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 standardlogging(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.mixinsandContextInstanceMixinstill exist and custom classes built on them migrate unchanged; what was removed is the built-in context onBot,Dispatcherand Telegram types (see Dispatcher).types.ChatActionshelpers are removed. Use theaiogram.enums.chat_action.ChatActionenum with an explicit call, or theChatActionSenderhelper:# 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
ChatActionMiddlewareand 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:
SimpleI18nMiddleware— locale from the user’slanguage_codeConstI18nMiddleware— fixed localeFSMI18nMiddleware— locale stored in FSMsubclass
I18nMiddlewareand overrideget_localefor custom resolution (e.g. from a database)
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.
Вебхук¶
Спрощено налаштування веб-застосунку aiohttp.
aiogram can serialize a method returned from a handler — including file uploads — directly into the webhook HTTP response (make requests in response to updates); see Replying into the webhook response below for when this is actually enabled.
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 withSimpleRequestHandler(..., handle_in_background=False). The default ishandle_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()isdeserialize_telegram_object_to_python(), which produces the same payload from any method object (the API method name is included by default viainclude_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
Botis configured withDefaultBotProperties, pass them too —deserialize_telegram_object_to_python(method, default=bot.default, ...)— otherwise defaults such asparse_modeare 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
serverparameter has been moved from theBotinstance toapiparameter of theBaseSession.The constant
aiogram.bot.api.TELEGRAM_PRODUCTIONhas been moved toaiogram.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 raisesClientDecodeError, bypassingexcept TelegramAPIErrorhandlers entirely (see the Exceptions section).