aiogram keyboards
Use direct InlineKeyboardMarkup(inline_keyboard=[[...]]) for small static layouts;
use builders when buttons or rows are generated dynamically.
python skills/using-aiogram/tools/get-type.py InlineKeyboardButton
python skills/using-aiogram/tools/get-type.py KeyboardButton
when to use this skill
- building a menu, a confirmation prompt, or a selection list
- laying buttons out in rows
- pagination
- asking the user for a contact, a location, or a chat
- deciding whether to edit the current message or send a new one
- removing a reply keyboard
inline versus reply
| Inline | Reply | |
|---|---|---|
| attached to | a specific message | the chat's input area |
| type | InlineKeyboardMarkup |
ReplyKeyboardMarkup |
| builder | InlineKeyboardBuilder |
ReplyKeyboardBuilder |
| press produces | depends on action: callback, URL, inline switch, etc. | text or requested contact/location/service data |
| use for | menus, actions on an item, pagination, confirmation | persistent input choices, contact/location requests |
Ordinary reply buttons send text, distinct from contact/location/request buttons, so handling them means matching strings — which breaks across locales and is spoofable by anyone typing the same text. Prefer inline keyboards for anything that performs an action.
InlineKeyboardBuilder
from aiogram.filters.callback_data import CallbackData
from aiogram.utils.keyboard import InlineKeyboardBuilder
class Item(CallbackData, prefix="item"):
item_id: int
builder = InlineKeyboardBuilder()
builder.button(text="Open docs", url="https://docs.aiogram.dev/")
builder.button(text="Pick", callback_data=Item(item_id=1))
builder.button(text="Copy code", callback_data="copy")
builder.adjust(2, 1)
markup = builder.as_markup()
assert [len(row) for row in markup.inline_keyboard] == [2, 1]
Verified button() signature — these are the button kinds Telegram accepts:
button(*, text, icon_custom_emoji_id=None, style=None, url=None,
callback_data=None, web_app=None, login_url=None,
switch_inline_query=None, switch_inline_query_current_chat=None,
switch_inline_query_chosen_chat=None, copy_text=None,
callback_game=None, pay=None, **kwargs)
callback_data accepts a str or a CallbackData instance, which is packed for you.
Bot API 10.3 adds disabled=DisabledButton() (passed through builder **kwargs),
ButtonStyle.LINK, and InlineKeyboardMarkup.force_reply. The last field enables reply
input alongside an inline keyboard and cannot be changed when editing that keyboard.
In 3.31.0 the inline builder's as_markup(**kwargs) ignores extra keyword arguments,
so construct InlineKeyboardMarkup explicitly when setting force_reply. Reply-keyboard
builders do forward markup options.
from aiogram.enums import ButtonStyle
from aiogram.types import DisabledButton, InlineKeyboardMarkup
from aiogram.utils.keyboard import InlineKeyboardBuilder
builder = InlineKeyboardBuilder()
builder.button(text="Unavailable", disabled=DisabledButton())
builder.button(text="Documentation", url="https://docs.aiogram.dev/", style=ButtonStyle.LINK)
markup = InlineKeyboardMarkup(inline_keyboard=builder.export(), force_reply=True)
assert markup.inline_keyboard[0][0].disabled is not None
assert markup.force_reply is True
layout
| Method | Effect |
|---|---|
button(**kwargs) |
append one button to the flat list |
add(*buttons) |
append prebuilt button objects |
row(*buttons, width=None) |
close the current row and add an explicit one |
adjust(*sizes, repeat=False) |
reflow everything into rows of the given widths |
attach(other) |
append another builder's rows |
copy() |
independent duplicate |
export() |
a deep copy of the list[list[Button]] |
as_markup(**kwargs) |
produce the markup model |
from_markup(markup) |
build from an existing markup (class method) |
from aiogram.types import InlineKeyboardButton
from aiogram.utils.keyboard import InlineKeyboardBuilder
main = InlineKeyboardBuilder()
for index in range(5):
main.button(text=str(index), callback_data=f"n:{index}")
main.adjust(3, repeat=True) # 3, 3, ... -> rows of 3, 2
footer = InlineKeyboardBuilder()
footer.row(InlineKeyboardButton(text="Close", callback_data="close"))
main.attach(footer)
assert [len(row) for row in main.export()] == [3, 2, 1]
adjust(2, 3, repeat=True) cycles widths 2, 3, 2, 3. With repeat=False,
the final width is repeated after the supplied sizes are exhausted.
ReplyKeyboardBuilder
from aiogram.types import KeyboardButton
from aiogram.utils.keyboard import ReplyKeyboardBuilder
builder = ReplyKeyboardBuilder()
builder.add(KeyboardButton(text="Share phone", request_contact=True))
builder.add(KeyboardButton(text="Share location", request_location=True))
builder.adjust(1)
markup = builder.as_markup(
resize_keyboard=True,
input_field_placeholder="Choose an option",
)
assert markup.resize_keyboard is True
as_markup(**kwargs) forwards keyword arguments to the markup model, which is how
resize_keyboard, one_time_keyboard, selective, and input_field_placeholder are
set.
Always pass resize_keyboard=True. Without it Telegram renders oversized buttons.
removing a reply keyboard
from aiogram import Router
from aiogram.types import Message, ReplyKeyboardRemove
router = Router(name="remove")
@router.message()
async def finish(message: Message) -> None:
"""A reply keyboard persists until explicitly removed - it outlives the flow."""
await message.answer("Done.", reply_markup=ReplyKeyboardRemove())
ForceReply() makes the client open the reply composer pointed at your message — useful
for a single free-text answer without a state machine.
special buttons
from aiogram.types import (
CopyTextButton,
KeyboardButton,
KeyboardButtonRequestUsers,
WebAppInfo,
)
from aiogram.utils.keyboard import InlineKeyboardBuilder, ReplyKeyboardBuilder
inline = InlineKeyboardBuilder()
inline.button(text="Open app", web_app=WebAppInfo(url="https://example.com/app"))
inline.button(text="Copy token", copy_text=CopyTextButton(text="ABC-123"))
inline.button(text="Share this bot", switch_inline_query="")
reply = ReplyKeyboardBuilder()
reply.add(
KeyboardButton(
text="Choose users",
request_users=KeyboardButtonRequestUsers(request_id=1, max_quantity=3),
)
)
assert len(inline.export()[0]) >= 1
switch_inline_query=""opens the chat picker so the user can share your bot.switch_inline_query_current_chatstays in the current chat.request_users/request_chatdeliver ausers_shared/chat_sharedservice message, handled withF.users_shared, not with a callback query.web_apprequires HTTPS. Validate the init data server-side — seeaiogram.utils.web_app.
pagination
from aiogram.filters.callback_data import CallbackData
from aiogram.utils.keyboard import InlineKeyboardBuilder
PAGE_SIZE = 5
class Nav(CallbackData, prefix="nav"):
page: int
def paginate(items: list[str], page: int) -> InlineKeyboardBuilder:
last = max(0, (len(items) - 1) // PAGE_SIZE)
page = min(max(page, 0), last) # clamp: never trust the payload
builder = InlineKeyboardBuilder()
if not items:
return builder
for offset, item in enumerate(items[page * PAGE_SIZE : (page + 1) * PAGE_SIZE]):
builder.button(text=item, callback_data=f"pick:{page * PAGE_SIZE + offset}")
builder.adjust(1, repeat=True)
controls = InlineKeyboardBuilder()
if page > 0:
controls.button(text="‹", callback_data=Nav(page=page - 1))
controls.button(text=f"{page + 1}/{last + 1}", callback_data=Nav(page=page))
if page < last:
controls.button(text="›", callback_data=Nav(page=page + 1))
controls.adjust(3)
builder.attach(controls)
return builder
assert paginate([], 0).export() == []
assert [len(r) for r in paginate([str(i) for i in range(12)], 0).export()][-1] == 2
assert [len(r) for r in paginate([str(i) for i in range(12)], 1).export()][-1] == 3
Three rules: clamp the page (the payload is user-controlled), always show where the user is, and handle the empty-list case rather than rendering a lone page counter.
edit versus send
| Situation | Do |
|---|---|
| navigating a menu, paging, toggling a selection | edit_text / edit_reply_markup |
| the previous message must remain in history | send a new one |
| the message is older than Telegram allows editing | send a new one |
| the message has media and you are changing media | edit_media |
from aiogram import Router
from aiogram.exceptions import TelegramBadRequest
from aiogram.types import CallbackQuery, Message
from aiogram.utils.keyboard import InlineKeyboardBuilder
router = Router(name="edit")
@router.callback_query()
async def refresh(callback: CallbackQuery) -> None:
await callback.answer()
if not isinstance(callback.message, Message):
return
builder = InlineKeyboardBuilder()
builder.button(text="Refresh", callback_data="refresh")
try:
await callback.message.edit_reply_markup(reply_markup=builder.as_markup())
except TelegramBadRequest as error:
if "message is not modified" not in str(error):
raise
Editing to identical text and identical markup raises
TelegramBadRequest: message is not modified. Either make sure something changed — an
active-item marker, a timestamp — or catch it as above.
callback.message may be an InaccessibleMessage when the original is too old. Check
before touching its fields.
keep markup out of handlers
Keep reusable or complex keyboard construction in the existing keyboard module and text in the localisation layer. A small one-off keyboard can stay local to its handler.
from aiogram.utils.keyboard import InlineKeyboardBuilder
def confirmation_keyboard(token: str) -> InlineKeyboardBuilder:
"""One function per menu; handlers call it and pass the markup along."""
builder = InlineKeyboardBuilder()
builder.button(text="Yes", callback_data=f"ok:{token}")
builder.button(text="No", callback_data=f"no:{token}")
builder.adjust(2)
return builder
assert len(confirmation_keyboard("t").export()[0]) == 2
Returning the builder rather than the markup lets callers attach() more rows.
checklist
- markup built with a builder, never nested lists
callback_dataproduced by aCallbackDatafactoryresize_keyboard=Trueon every reply keyboard- reply keyboards removed with
ReplyKeyboardRemovewhen the flow ends - pagination clamps the page number and handles an empty list
- edits guarded against "message is not modified"
callback.messagetype-checked before use- keyboards live in their own module
see also
aiogram-callback-data— the payloads on these buttonsaiogram-routing— routing the resulting callback queriesaiogram-formatting— the text beside the markupskills/using-aiogram/references/telegram-quirks.md— edit limits and callback expiry