python-telegram-bot
python-telegram-bot (PTB) is the standard Python wrapper for the Telegram Bot API. The telegram module mirrors every type and method of Bot API 10.0 one to one; the telegram.ext module adds the framework you actually build with: Application, handler classes for commands, messages, callback queries and multi-step conversations, a job queue, persistence, and rate limiting. Since v20 the whole library is asyncio-based: every handler is an async function and every bot method is awaited. The only required dependency is httpx; everything else (tornado for webhooks, APScheduler for the job queue, cachetools, aiolimiter) is an optional extra.
The default choice for Telegram bots in Python and one of the best-run projects on PyPI. Commit to asyncio, install the extras you need, and ignore every tutorial written before 2023.
Use it if
- You are building a Telegram bot in Python that is more than a one-off script: the handler system, ConversationHandler, and job queue cover what you would otherwise hand-roll on top of raw HTTP calls
- You want same-week Bot API coverage: the library tracks Telegram's API releases closely (Bot API 10.0 in v22.8) and documents a forward-compatibility path for anything not yet wrapped
- You care about maintenance signals: pushed August 2026, roughly 29,400 stars, only 34 open issues and PRs combined, sigstore-signed releases since v21.4, and tested against Python 3.10 through 3.14 including free-threaded builds
- Your bot is already async or lives next to other asyncio code (FastAPI, aiohttp), since PTB shares the event loop instead of spawning worker threads
- Your surrounding code is synchronous: since v20 there is no sync API at all, so calling the bot from a Django view, a Celery task, or a cron script means running an event loop yourself, and for send-one-message jobs a plain httpx POST to the Bot API is less machinery
- You learn from tutorials or LLM output: the pre-2023 Updater/dispatcher API is dead, and the majority of blog posts, Stack Overflow answers, and model-generated snippets still use it and will not run on v20+
- You need MTProto features: the Bot API caps downloads at 20 MB and uploads at 50 MB unless you self-host a Bot API server, and it cannot act as a user account; Telethon or Pyrogram cover that ground
- You need threads: the maintainers state PTB does not aim to be thread-safe, so sharing an Application, ConversationHandler, or persistence across threads is on you
- You dislike fast-moving version numbers: v20 to v22.8 in about three years, with deprecation cycles riding each Bot API release, so unpinned upgrades regularly need code changes
Setup reality
pip install python-telegram-bot is pure Python and takes seconds on 3.10+. The traps come later: JobQueue silently does not exist (app.job_queue is None) until you install the job-queue extra for APScheduler, run_webhook raises until you install the webhooks extra for tornado, and arbitrary callback_data needs the callback-data extra. The one hard dependency, httpx, is pinned below 0.29 and can collide with whatever else in your project wants a newer httpx. The biggest cost is not install at all, it is that nearly every tutorial older than 2023 targets the removed Updater/dispatcher API, so you must work from the current docs and examples only.
Patterns
Smallest working botminimal-bot
from telegram import Update
from telegram.ext import Application, CommandHandler, ContextTypes
async def start(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
await update.message.reply_text("I am alive")
app = Application.builder().token("YOUR_BOT_TOKEN").build()
app.add_handler(CommandHandler("start", start))
app.run_polling()run_polling() creates and manages the event loop itself, so there is no asyncio.run() wrapper and nothing after it executes until shutdown. The token comes from @BotFather. Every handler must be an async def and every bot call must be awaited.
Recognize and replace dead pre-v20 tutorial codeavoid-v13-updater
# Pre-2023 tutorials and most LLM output look like this. It does NOT run on v20+:
# updater = Updater("TOKEN", use_context=True)
# updater.dispatcher.add_handler(CommandHandler("start", start))
# updater.start_polling(); updater.idle()
# Current API since v20:
app = Application.builder().token("TOKEN").build()
app.add_handler(CommandHandler("start", start)) # start is async def now
app.run_polling()The library went fully async at v20 (January 2023). Updater still exists but only as the internal update fetcher; dispatcher, use_context, and sync handlers are gone. If a snippet has no async/await, it is for the old API and will fail with import or attribute errors.
Command handler that reads argumentscommand-arguments
async def caps(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
if not context.args:
await update.message.reply_text("Usage: /caps some text")
return
await update.message.reply_text(" ".join(context.args).upper())
app.add_handler(CommandHandler("caps", caps))context.args is the whitespace-split text after the command, already stripped of the command itself. In groups, /caps@YourBotName also matches, and Telegram only forwards group messages to the bot per its privacy mode unless that is disabled in @BotFather.
Message handlers with filtersmessage-filters
from telegram.ext import MessageHandler, filters
async def echo(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
await update.message.reply_text(update.message.text)
app.add_handler(MessageHandler(filters.TEXT & ~filters.COMMAND, echo))
app.add_handler(MessageHandler(filters.PHOTO, on_photo))
app.add_handler(MessageHandler(filters.Regex(r"(?i)refund"), on_refund))The lowercase filters module replaced the v13 Filters class. Compose with &, |, ~. Keep ~filters.COMMAND on catch-all text handlers or they swallow commands too; within one handler group only the first match runs, so registration order matters.
Inline keyboard and callback query handlinginline-keyboard-callbacks
from telegram import InlineKeyboardButton, InlineKeyboardMarkup
from telegram.ext import CallbackQueryHandler
async def menu(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
kb = [[InlineKeyboardButton("Approve", callback_data="ok"),
InlineKeyboardButton("Reject", callback_data="no")]]
await update.message.reply_text("Decide:", reply_markup=InlineKeyboardMarkup(kb))
async def on_button(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
query = update.callback_query
await query.answer()
await query.edit_message_text(f"You chose: {query.data}")
app.add_handler(CommandHandler("menu", menu))
app.add_handler(CallbackQueryHandler(on_button))Always await query.answer(), even with no text, or the user's client shows a loading spinner for up to a minute. callback_data is capped at 64 bytes by Telegram; for real objects install the callback-data extra and enable arbitrary_callback_data in the builder.
Multi-step conversationconversation-handler
from telegram.ext import ConversationHandler
NAME, AGE = range(2)
async def signup(update, context):
await update.message.reply_text("Your name?")
return NAME
async def got_name(update, context):
context.user_data["name"] = update.message.text
await update.message.reply_text("Your age?")
return AGE
async def got_age(update, context):
await update.message.reply_text(f"Done, {context.user_data['name']}.")
return ConversationHandler.END
async def cancel(update, context):
return ConversationHandler.END
app.add_handler(ConversationHandler(
entry_points=[CommandHandler("signup", signup)],
states={NAME: [MessageHandler(filters.TEXT & ~filters.COMMAND, got_name)],
AGE: [MessageHandler(filters.Regex(r"^\d+$"), got_age)]},
fallbacks=[CommandHandler("cancel", cancel)],
))Each handler returns the next state, or ConversationHandler.END. State lives in memory per chat, so a restart drops every conversation mid-flow unless you configure a persistence class. Always ship a fallback; users who get stuck in a state otherwise have no exit.
Send photos and documentssend-files-photos
from pathlib import Path
async def report(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
msg = await update.message.reply_photo(
photo=Path("chart.png"), caption="Daily chart")
context.bot_data["chart_file_id"] = msg.photo[-1].file_id
await context.bot.send_document(
chat_id=update.effective_chat.id,
document=open("report.pdf", "rb"),
filename="report.pdf")photo/document accept a Path, a file object, raw bytes, an HTTP URL, or a Telegram file_id. Reuse the file_id from the returned Message to resend without re-uploading. Bot API limits: 50 MB uploads, 20 MB downloads; photos get recompressed, so send originals as documents.
Run on a webhook instead of pollingwebhook-vs-polling
# pip install "python-telegram-bot[webhooks]" (pulls tornado)
app.run_webhook(
listen="127.0.0.1",
port=8443,
url_path="telegram",
secret_token="random-string-you-generate",
webhook_url="https://example.com/telegram",
)run_polling and run_webhook are interchangeable at the end of the same setup, so develop on polling and deploy on webhooks. Telegram only delivers to HTTPS on ports 443, 80, 88, or 8443, so in practice you sit behind nginx for TLS. The secret_token check is how you reject forged POSTs.
Scheduled and repeating jobsjob-queue
# pip install "python-telegram-bot[job-queue]" (pulls APScheduler)
async def remind(context: ContextTypes.DEFAULT_TYPE) -> None:
await context.bot.send_message(chat_id=context.job.chat_id, text="Reminder!")
async def set_timer(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
context.job_queue.run_once(remind, when=300, chat_id=update.effective_chat.id)
app.job_queue.run_repeating(nightly_sync, interval=86400, first=10)Without the job-queue extra, app.job_queue is simply None and the first attribute access dies with a confusing AttributeError. Job callbacks receive only context, no update; pass chat_id or data into run_once/run_repeating and read them back off context.job. Jobs are in-memory and do not survive restarts.
Register an error handlererror-handler
import logging
logger = logging.getLogger(__name__)
async def on_error(update: object, context: ContextTypes.DEFAULT_TYPE) -> None:
logger.error("Update %s caused error", update, exc_info=context.error)
# optionally: await context.bot.send_message(chat_id=DEV_CHAT_ID, text=str(context.error))
app.add_error_handler(on_error)Without this, handler exceptions are only logged and the bot keeps running as if nothing happened, so bugs go unnoticed for days. Transient network errors (telegram.error.TimedOut, NetworkError) land here too; do not treat every invocation as a crash.
Send a message from synchronous codesync-code-bridge
import asyncio
from telegram import Bot
def notify(text: str) -> None:
async def _send():
async with Bot("YOUR_BOT_TOKEN") as bot:
await bot.send_message(chat_id=CHAT_ID, text=text)
asyncio.run(_send())There is no sync API since v20, so scripts and cron jobs need this bridge. asyncio.run() per call is fine for one-shot notifications but do not call it from inside a running event loop; in async code just use the Bot instance directly.
Process updates concurrentlyconcurrent-updates
app = (
Application.builder()
.token("YOUR_BOT_TOKEN")
.concurrent_updates(True)
.build()
)By default updates are handled strictly one at a time, so one slow handler (an API call, a download) stalls every other chat. concurrent_updates(True) processes up to 256 updates at once, but per-update ordering guarantees are gone and shared state in user_data/bot_data now needs care.
Alternatives
| Package | Registry | Pick it when |
|---|---|---|
| aiogram | PyPI | You want the other modern async framework: router and middleware architecture with a built-in FSM instead of PTB's handler groups |
| pyTelegramBotAPI | PyPI | You want a synchronous API for a small script and refuse to touch asyncio (it also ships an async variant) |
| Telethon | PyPI | You need MTProto: user accounts, channels history, or files past the Bot API's 20 MB download and 50 MB upload limits |