Inline keyboards are one of the most powerful features of Telegram bots. They let you add clickable buttons directly below a message, turning a simple text response into an interactive interface. Users can navigate menus, confirm actions, pick options, or trigger workflows — all without typing a single command.
This guide covers everything you need to know about inline keyboards: how the JSON structure works, limits and best practices, and ready-to-use code examples in Python.
An inline keyboard is a JSON object attached to a message via the reply_markup parameter. It contains an array of rows, where each row is an array of buttons. Each button has a visible text label and an action — most commonly a callback_data string that your bot receives when the user taps the button.
{
"inline_keyboard": [
[
{"text": "Yes ✅", "callback_data": "confirm_yes"},
{"text": "No ❌", "callback_data": "confirm_no"}
],
[
{"text": "Cancel", "callback_data": "cancel"}
]
]
}
This creates two rows: the first with "Yes" and "No" side by side, and the second with a full-width "Cancel" button below them.
Telegram supports several button types beyond simple callbacks:
| Type | Field | Use case |
|---|---|---|
| Callback | callback_data | Triggers a callback query your bot handles. Max 64 bytes. |
| URL | url | Opens a link in the user's browser. |
| Switch Inline | switch_inline_query | Prompts user to pick a chat and starts inline query. |
| Web App | web_app | Opens a Telegram Mini App (WebApp). |
| Login | login_url | Telegram Login widget for websites. |
| Pay | pay | Payment button (must be first button in first row). |
Telegram enforces a few constraints on inline keyboards that are worth knowing before you build complex layouts:
callback_data is limited to 1–64 bytes. Keep your identifiers short. Instead of user_clicked_the_settings_menu_item, use settings or s:1. You can store extra state in your database and just pass an ID in the callback.
Each row can hold up to 8 buttons. If you add more, Telegram will reject the message. In practice, 3–4 buttons per row works best for readability on mobile screens.
There's no official limit on the number of rows, but Telegram recommends keeping keyboards compact. A keyboard with 10+ rows pushes the message content off-screen and creates a poor UX. If you need many options, consider pagination.
from telegram import InlineKeyboardButton, InlineKeyboardMarkup, Update
from telegram.ext import (
Application, CommandHandler,
CallbackQueryHandler, ContextTypes,
)
async def menu(update: Update, context: ContextTypes.DEFAULT_TYPE):
keyboard = [
[
InlineKeyboardButton("📊 Stats", callback_data="stats"),
InlineKeyboardButton("⚙️ Settings", callback_data="settings"),
],
[InlineKeyboardButton("❓ Help", callback_data="help")],
]
reply_markup = InlineKeyboardMarkup(keyboard)
await update.message.reply_text("Choose an option:", reply_markup=reply_markup)
async def button_handler(update: Update, context: ContextTypes.DEFAULT_TYPE):
query = update.callback_query
await query.answer() # always answer callback queries
await query.edit_message_text(f"You selected: {query.data}")
query.answer() even if you don't want to show a notification. If you don't, Telegram shows a loading spinner on the button for 30 seconds.
Static keyboards work fine for fixed menus, but most real bots need to generate buttons from dynamic data — a list of categories, search results, items in a database. The pattern is straightforward: loop over your data, build the buttons, then chunk them into rows.
from telegram import InlineKeyboardButton, InlineKeyboardMarkup
# Suppose you have a list of categories from your database
categories = [
{"id": "food", "name": "🍕 Food"},
{"id": "tech", "name": "💻 Tech"},
{"id": "sport", "name": "⚽ Sport"},
{"id": "music", "name": "🎵 Music"},
{"id": "travel", "name": "✈️ Travel"},
]
def build_category_keyboard(items, columns=2):
"""Build a keyboard with `columns` buttons per row."""
buttons = [
InlineKeyboardButton(item["name"], callback_data=f"cat:{item['id']}")
for item in items
]
# Slice the flat list into rows of N buttons
rows = [buttons[i:i + columns] for i in range(0, len(buttons), columns)]
return InlineKeyboardMarkup(rows)
# Use it
reply_markup = build_category_keyboard(categories, columns=2)
The slicing pattern [i:i+columns] is the idiomatic Python way to chunk a flat list into rows. Change columns to switch between 2-column, 3-column, or single-column layouts without rewriting the logic.
Notice the callback_data uses a cat: prefix. This is the routing convention — when the user taps a button, your handler can split on : to know it's a category selection and which one. We'll see this pattern again in pagination.
What happens when your list of items doesn't fit in one screen? Telegram allows many rows in a keyboard, but a tall keyboard pushes your message off-screen and creates a bad UX. The standard solution is pagination — show N items per page with previous/next navigation buttons.
from telegram import InlineKeyboardButton, InlineKeyboardMarkup, Update
from telegram.ext import ContextTypes
PAGE_SIZE = 5
def build_paginated_keyboard(items, page=0, prefix="item"):
"""Render one page of items with prev/next navigation."""
start = page * PAGE_SIZE
end = start + PAGE_SIZE
visible = items[start:end]
# One item per row
rows = [
[InlineKeyboardButton(item["name"], callback_data=f"{prefix}:{item['id']}")]
for item in visible
]
# Navigation row: « Prev | Page X | Next »
nav = []
if page > 0:
nav.append(InlineKeyboardButton("« Prev", callback_data=f"page:{page-1}"))
nav.append(InlineKeyboardButton(f"Page {page+1}", callback_data="noop"))
if end < len(items):
nav.append(InlineKeyboardButton("Next »", callback_data=f"page:{page+1}"))
rows.append(nav)
return InlineKeyboardMarkup(rows)
async def handle_pagination(update: Update, context: ContextTypes.DEFAULT_TYPE):
query = update.callback_query
await query.answer()
if query.data.startswith("page:"):
page = int(query.data.split(":")[1])
items = context.bot_data["my_list"] # your full list, stored anywhere
keyboard = build_paginated_keyboard(items, page=page)
await query.edit_message_reply_markup(reply_markup=keyboard)
Three things worth noting in this pattern:
The center "Page X" button uses callback_data="noop" — a non-action. You can either ignore it in your handler or use it to show a tooltip. Don't omit it; the visual feedback of seeing "Page 2 of 5" reassures the user about where they are.
"Prev" and "Next" only appear when there's somewhere to go — on the first page Prev is hidden, on the last page Next is hidden. Always-visible disabled buttons look broken; conditionally hiding them looks intentional.
The handler uses edit_message_reply_markup instead of edit_message_text — this updates only the buttons, leaving the message text untouched. Much faster perceived response than re-sending the full message.
If you've worked with inline keyboards for more than a few hours, you've probably hit at least one of these errors. Here's what they mean and how to fix them quickly.
| Error | Cause and fix |
|---|---|
Bad Request: Button_data_invalid |
Your callback_data is invalid — usually because it's longer than 64 bytes. Remember: 64 bytes, not characters. UTF-8 emoji and non-ASCII chars take multiple bytes each. Keep callback_data short and ASCII-only; store extra state in your database keyed by a short ID. |
Bad Request: message is not modified |
You called edit_message_text or edit_message_reply_markup with content identical to what's already shown. Telegram refuses no-op edits. Fix: check that the new content is actually different before editing, or wrap the call in a try/except and ignore this specific error. |
Bad Request: there is no text in the message to edit |
You're trying to edit_message_text on a message that only contains media (a photo, video, or document with no text). Use edit_message_caption instead — or edit_message_reply_markup if you only need to update buttons. |
Query is too old and response timeout expired |
You didn't call query.answer() within ~15 seconds of receiving the callback. The user already sees the loading spinner stuck. Fix: call query.answer() as the very first line of every callback handler, before any slow operations. |
Bad Request: BUTTON_TEXT_INVALID |
Button label is empty or contains only whitespace. Even invisible characters (zero-width spaces) trigger this. Always strip and validate user-provided text before putting it in a button label. |
Use emoji at the start of button labels. It makes buttons scannable at a glance. Users process icons faster than text — "📊 Stats" reads faster than "View Statistics".
Group related actions in the same row. Put "Yes" and "No" side by side, not stacked vertically. Put destructive actions like "Delete" on their own row, preferably at the bottom.
Keep callback_data structured. Use a prefix convention like menu:main, menu:settings, page:2. This makes it easier to route callbacks with pattern matching instead of long if/else chains.
Update the keyboard after a tap instead of sending a new message. Use editMessageText or editMessageReplyMarkup to change the buttons in place. This keeps the chat clean and feels more app-like.
Inline keyboards appear attached to a specific message — they stay with that message in the chat history. Reply keyboards (the "custom keyboard" type) replace the user's normal Telegram keyboard at the bottom of the screen and are shown until dismissed. Use inline for actions tied to specific content (confirm, navigate, select); use reply for persistent menus that should always be available while chatting.
Telegram allows up to 8 buttons per row and doesn't enforce a hard limit on rows, but practical UX limits are tighter. Most bots use 2-4 buttons per row and 3-8 rows total. Beyond that, switch to pagination — see the pagination pattern above.
64 bytes, not characters. UTF-8 characters can take 1-4 bytes each, so emoji and non-ASCII text consume the budget fast. Keep callback_data short and ASCII; store the full state in your database keyed by a short ID.
Yes. Instead of callback_data, use the url field: InlineKeyboardButton("Visit", url="https://example.com"). The link opens in the user's external browser or in-app browser, depending on their settings.
Yes — that's the whole point of edit_message_reply_markup. It changes only the buttons of an existing message, keeping the original text intact. This is the standard pattern for menus, pagination, and toggles.
Yes. Inline keyboards work in private chats, groups, supergroups, and channels. In groups, callbacks include the user who tapped — so you can implement per-user logic (e.g., "only the original asker can tap this confirmation").
Use the web_app field with a URL pointing to your Mini App: InlineKeyboardButton("Open App", web_app=WebAppInfo(url="https://your-app.com")). The URL must be HTTPS and registered as a Web App via BotFather.
Not directly — every user in a group sees the same buttons. But you can check the user_id in the callback handler and ignore taps from unauthorized users (showing them a "not allowed" alert via query.answer(text="...", show_alert=True)).
Build your keyboard visually — no coding needed. Add buttons, set callback_data, and export the code in JSON, Python, format.
Try the Inline Keyboard Builder →