# Aiogram Keyboards

> Use when building aiogram 3 inline/reply keyboards, menus, pagination, or Mini App buttons.

- Skill: `ballisarium/aiogram-keyboards` (Agent Skill, multi-file: 2 files)
- Install (CLI): `npx skillmds@latest add ballisarium/aiogram-keyboards`
- Raw SKILL.md: https://api.skillmd.com/api/skills/ballisarium/aiogram-keyboards/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Coding & Dev Tools
- Author: ballisarium (https://skillmd.com/u/ballisarium)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/ballisarium/aiogram-keyboards

---


# aiogram keyboards

Use direct `InlineKeyboardMarkup(inline_keyboard=[[...]])` for small static layouts;
use builders when buttons or rows are generated dynamically.

```bash
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

```python
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:

```text
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.

```python
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) |

```python
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

```python
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,
    one_time_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

```python
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

```python
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_chat` stays in the current chat.
- `request_users` / `request_chat` deliver a `users_shared` / `chat_shared` service
  message, handled with `F.users_shared`, not with a callback query.
- `web_app` requires HTTPS. Validate the init data server-side — see
  `aiogram.utils.web_app`.

## pagination

```python
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` |

```python
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.

```python
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_data` produced by a `CallbackData` factory
- `resize_keyboard=True` on every reply keyboard
- reply keyboards removed with `ReplyKeyboardRemove` when the flow ends
- pagination clamps the page number and handles an empty list
- edits guarded against "message is not modified"
- `callback.message` type-checked before use
- keyboards live in their own module

## see also

- `aiogram-callback-data` — the payloads on these buttons
- `aiogram-routing` — routing the resulting callback queries
- `aiogram-formatting` — the text beside the markup
- `skills/using-aiogram/references/telegram-quirks.md` — edit limits and callback expiry

