← Back to Blog

How to Build Telegram Inline Keyboards — Visual Guide with Code

March 2026 · 6 min read

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.

How Inline Keyboards Work

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.

Button Types

Telegram supports several button types beyond simple callbacks:

TypeFieldUse case
Callbackcallback_dataTriggers a callback query your bot handles. Max 64 bytes.
URLurlOpens a link in the user's browser.
Switch Inlineswitch_inline_queryPrompts user to pick a chat and starts inline query.
Web Appweb_appOpens a Telegram Mini App (WebApp).
Loginlogin_urlTelegram Login widget for websites.
PaypayPayment button (must be first button in first row).

Limits and Rules

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.

Python Example (python-telegram-bot)

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}")
Important: Always call 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.

Dynamic Keyboards: Building Buttons from Data

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.

Pagination Pattern

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.

Common Errors and How to Fix Them

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.

ErrorCause 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.
Tip: When building keyboards that trigger frequent updates from many users at once, be mindful of Telegram's rate limits. See our guide to Telegram bot rate limits for the exact limits and how to queue messages safely.

Best Practices for Inline Keyboard UX

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.

Frequently Asked Questions

What's the difference between inline keyboards and reply keyboards?

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.

How many buttons can I put in one keyboard?

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.

What's the maximum length of callback_data?

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.

Can I open a URL from a button?

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.

Can I update a keyboard without sending a new message?

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.

Do inline keyboards work in groups?

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").

How do I open a Telegram Mini App from a button?

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.

Can I make a button only some users can tap?

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 →