An inline keyboard is a grid of InlineKeyboardButton objects wrapped in an InlineKeyboardMarkup and attached to a message with reply_markup, and every button press arrives as a CallbackQuery you must answer.
The must in that sentence is doing real work. If you do not call answer() on the callback query, Telegram shows the user a loading spinner on the button for several seconds and then gives up. It is the single most common bug in bot code, and it looks like network latency rather than a missing line.
Table of contents
- The minimum working keyboard
- Answering with feedback
- The 64-byte limit
- Routing presses without one giant handler
- The security bit everyone skips
- Running it somewhere that stays up
- How this fits the rest of the stack
- FAQ
The minimum working keyboard
pip install "python-telegram-bot[job-queue]"
from telegram import InlineKeyboardButton, InlineKeyboardMarkup, Update
from telegram.ext import Application, CommandHandler, CallbackQueryHandler, ContextTypes
async def start(update: Update, context: ContextTypes.DEFAULT_TYPE):
keyboard = [
[InlineKeyboardButton("Deploy", callback_data="deploy"),
InlineKeyboardButton("Rollback", callback_data="rollback")],
[InlineKeyboardButton("Status", callback_data="status")],
]
await update.message.reply_text(
"What do you need?",
reply_markup=InlineKeyboardMarkup(keyboard),
)
async def on_button(update: Update, context: ContextTypes.DEFAULT_TYPE):
query = update.callback_query
await query.answer() # do this first, always
await query.edit_message_text(f"Running: {query.data}")
app = Application.builder().token(TOKEN).build()
app.add_handler(CommandHandler("start", start))
app.add_handler(CallbackQueryHandler(on_button))
app.run_polling()
The structure is a list of rows, each row a list of buttons. That nesting is what controls layout — one inner list per row, however many buttons per row you want.
await query.answer() before anything else. It tells Telegram you received the press and clears the client-side spinner. Do it first, before any database call or slow work, so a slow handler does not look like a broken button.
Answering with feedback
answer() takes optional arguments that give the user immediate confirmation without editing the message.
# a toast at the top of the chat
await query.answer("Deploy queued")
# a modal the user has to dismiss -- use sparingly
await query.answer("This will restart the service.", show_alert=True)
The text is capped at 200 characters. Anything longer belongs in the message body, not the toast.
For editing the message afterwards, you have three choices, and picking the right one matters more than it seems:
edit_message_text— replace the text and optionally the keyboard. The usual choice.edit_message_reply_markup— change only the buttons, leaving the text untouched. Good for toggles.- Send a new message — when the previous state is worth keeping visible in the chat history.
Editing a message to exactly the content it already has raises BadRequest: Message is not modified. This bites on toggle buttons where the user presses the same option twice. Catch it and move on.
from telegram.error import BadRequest
try:
await query.edit_message_text(text, reply_markup=markup)
except BadRequest as exc:
if "not modified" not in str(exc):
raise
The 64-byte limit
callback_data is capped at 64 bytes by the Telegram API. Bytes, not characters — non-ASCII text eats it faster than you expect.
This is fine for "deploy" and immediately painful for anything carrying real context. The usual first attempt looks like this and breaks in production:
# will exceed 64 bytes as soon as names get long
callback_data = f"deploy:{project_name}:{environment}:{commit_sha}:{user_id}"
Two ways out.
Short keys plus server-side lookup. Store the real payload and put an identifier in the button.
import uuid
action_id = uuid.uuid4().hex[:12]
context.bot_data.setdefault("actions", {})[action_id] = {
"project": project_name,
"env": environment,
"sha": commit_sha,
}
button = InlineKeyboardButton("Deploy", callback_data=f"d:{action_id}")
Arbitrary callback data, which python-telegram-bot offers as a built-in. You pass any Python object and the library handles the mapping.
app = Application.builder().token(TOKEN).arbitrary_callback_data(True).build()
button = InlineKeyboardButton("Deploy", callback_data={"action": "deploy", "sha": sha})
Convenient, with one caveat that matters: the mapping lives in memory. Restart the bot and old buttons produce InvalidCallbackData. Handle it rather than letting it become an unhandled exception in your logs.
from telegram.ext import InvalidCallbackData
async def on_stale(update, context):
await update.callback_query.answer("That menu expired -- send /start again", show_alert=True)
app.add_handler(CallbackQueryHandler(on_stale, pattern=InvalidCallbackData))
Routing presses without one giant handler
A single CallbackQueryHandler with a long if/elif chain works and becomes unpleasant fast. pattern accepts a regex, so you can route by prefix.
app.add_handler(CallbackQueryHandler(on_deploy, pattern=r"^deploy:"))
app.add_handler(CallbackQueryHandler(on_rollback, pattern=r"^rollback:"))
app.add_handler(CallbackQueryHandler(on_page, pattern=r"^page:\d+$"))
Adopt a consistent action:arg1:arg2 shape and parse it in one place:
async def on_page(update, context):
query = update.callback_query
await query.answer()
_, page = query.data.split(":")
await query.edit_message_reply_markup(build_page_keyboard(int(page)))
Handlers are checked in registration order and the first match wins, so register specific patterns before general ones. A catch-all CallbackQueryHandler with no pattern registered first will swallow everything below it.
The security bit everyone skips
A callback query carries the id of whoever pressed the button — which is not necessarily the person the message was sent to. In a group chat, every member can press every button on every message the bot posts.
So a bot that posts a deploy menu in a team channel will happily accept a deploy from anyone in that channel, including someone who joined this morning.
ALLOWED = {123456789, 987654321}
async def on_deploy(update, context):
query = update.callback_query
if query.from_user.id not in ALLOWED:
await query.answer("Not authorised", show_alert=True)
return
await query.answer()
...
Check from_user.id on every handler that does something consequential, not just at menu creation. Hiding a button is not access control — the callback data is visible to anyone who can read the message, and a modified client can send whatever it likes.
Same principle as any API: the button is UI, the handler is the boundary.
Running it somewhere that stays up
run_polling() is right for development. A bot people depend on wants webhooks — Telegram posts updates to an HTTPS endpoint instead of your process asking repeatedly whether anything has happened.
app.run_webhook(
listen="0.0.0.0",
port=int(os.environ["PORT"]),
url_path=os.environ["WEBHOOK_SECRET"],
webhook_url=f"{os.environ['PUBLIC_URL']}/{os.environ['WEBHOOK_SECRET']}",
)
Webhooks need a public HTTPS URL with a valid certificate, which is the part that makes people stay on polling longer than they should. Polling costs you a request loop that runs forever, a delay between action and response, and a process that has to be running for anything to happen at all.
That is the shape RunxBuild handles: push the repository, get a route with TLS, inject the bot token and webhook secret as environment variables rather than committing them, and read runtime logs when a handler starts throwing. The url_path secret matters — an unauthenticated webhook endpoint accepts fabricated updates from anyone who finds it.
One more thing worth planning for: bot_data and user_data live in memory by default, so a redeploy loses every in-flight conversation and every arbitrary-callback mapping. Use a persistence backend or a real database for anything that should survive a restart.
How this fits the rest of the stack
A bot is a service with a public endpoint, a token to protect, and state that should outlive a restart. The RunxBuild hosting calculator puts the runtime, database, and bandwidth line items on one page so you can model what keeping it up actually costs.
Useful related references:
- Python Inline If: Select a Value Without Compressing the Logic
- Python Not Equal: != vs is not, and Why the Difference Bites
- Python Integer Division: Why // Floors and Why -7 // 2 Is -4
- Python services on RunxBuild
FAQ
How do I create an inline keyboard in python-telegram-bot?
Build a list of rows, each row a list of InlineKeyboardButton objects, wrap it in InlineKeyboardMarkup, and pass it as reply_markup when sending the message. The nesting controls layout — one inner list per row.
Why does my Telegram button show a loading spinner?
You did not call await query.answer() on the callback query. Telegram waits for that acknowledgement and eventually times out. Call it first in the handler, before any database work, so a slow handler does not look like a broken button.
What is the callback_data size limit?
64 bytes, not characters, so non-ASCII text consumes it faster. Either store the real payload server-side and put a short identifier in the button, or enable arbitrary_callback_data(True) — noting that its mapping is in memory and old buttons raise InvalidCallbackData after a restart.
Why do I get Message is not modified?
Editing a message to content identical to what it already shows raises BadRequest. It typically happens when a user presses the same toggle twice. Catch BadRequest and re-raise only if the message is not the not modified case.
Can anyone in a group press my bot’s buttons?
Yes. Every member of a chat can press any button on any message the bot posts, regardless of who the message was aimed at. Check query.from_user.id inside each consequential handler — hiding a button is not access control, since callback data is visible and a modified client can send anything.