Guide 28

Build a Telegram bot in Python with python-telegram-bot, step by step

python-telegram-bot is a mature, fully asynchronous wrapper around the Telegram Bot API. This guide builds a small but complete bot you can extend.

Guide4 min readUpdated

1. Create the bot in BotFather

  1. Open @BotFather in Telegram and send /newbot.
  2. Choose a display name and a username ending in bot.
  3. Copy the token. Treat it like a password: anyone with it controls the bot. See bot security.

While you are there, /setcommands sets the command menu, or you can set it from code later (step 5). The Telegram bots guide explains the platform concepts used here.

2. Set up the project

python-telegram-bot (PTB) requires Python 3.10 or newer at the time of writing; version 22.8 was the latest release when this guide was written.

mkdir mybot && cd mybot
python3 -m venv .venv && . .venv/bin/activate
pip install "python-telegram-bot[job-queue]==22.*"
echo 'BOT_TOKEN=123456:ABC...' > .env      # never commit this file
echo '.env' >> .gitignore

The job-queue extra adds scheduling (step 7). Add webhooks too if you will use a webhook: python-telegram-bot[job-queue,webhooks].

3. A first working bot

# bot.py
import logging, os
from telegram import Update
from telegram.ext import Application, CommandHandler, MessageHandler, ContextTypes, filters

logging.basicConfig(format="%(asctime)s %(levelname)s %(name)s: %(message)s", level=logging.INFO)
logging.getLogger("httpx").setLevel(logging.WARNING)   # httpx logs every request URL, which contains the token

async def start(update: Update, context: ContextTypes.DEFAULT_TYPE):
    await update.message.reply_text(f"Hi {update.effective_user.first_name}! Send me any text.")

async def echo(update: Update, context: ContextTypes.DEFAULT_TYPE):
    await update.message.reply_text(update.message.text)

def main():
    app = Application.builder().token(os.environ["BOT_TOKEN"]).build()
    app.add_handler(CommandHandler("start", start))
    app.add_handler(MessageHandler(filters.TEXT & ~filters.COMMAND, echo))
    app.run_polling(allowed_updates=Update.ALL_TYPES)

if __name__ == "__main__":
    main()

Run it with the token loaded (set -a; . ./.env; set +a; python bot.py) and send /start to your bot.

Notes:

  • Handlers are checked in order within a group; the first match handles the update.
  • filters.TEXT & ~filters.COMMAND matches text that is not a command, so /start is not echoed.
  • Silencing the httpx logger matters: at INFO level it prints request URLs, and Telegram URLs contain the token.

4. Conversations with ConversationHandler

Multi-step flows (ask a name, then an email) are a state machine. PTB's ConversationHandler maps states to handlers:

from telegram.ext import ConversationHandler

NAME, CITY = range(2)

async def signup(update, context):
    await update.message.reply_text("What is your name? (/cancel to stop)")
    return NAME

async def got_name(update, context):
    context.user_data["name"] = update.message.text.strip()[:64]
    await update.message.reply_text("Which city are you in?")
    return CITY

async def got_city(update, context):
    name = context.user_data["name"]
    await update.message.reply_text(f"Thanks {name}, saved {update.message.text.strip()[:64]}.")
    return ConversationHandler.END

async def cancel(update, context):
    await update.message.reply_text("Cancelled.")
    return ConversationHandler.END

conv = ConversationHandler(
    entry_points=[CommandHandler("signup", signup)],
    states={NAME: [MessageHandler(filters.TEXT & ~filters.COMMAND, got_name)],
            CITY: [MessageHandler(filters.TEXT & ~filters.COMMAND, got_city)]},
    fallbacks=[CommandHandler("cancel", cancel)],
    conversation_timeout=600,
)
app.add_handler(conv)   # add before the echo handler

Sketch the states first in the flow designer; it is much easier than debugging a tangled conversation.

5. Command menu and inline buttons

Set the command menu at startup with a post_init hook:

from telegram import BotCommand

async def post_init(app):
    await app.bot.set_my_commands([
        BotCommand("start", "Start the bot"),
        BotCommand("signup", "Register"),
        BotCommand("cancel", "Cancel the current action"),
    ])

app = Application.builder().token(os.environ["BOT_TOKEN"]).post_init(post_init).build()

For buttons under a message, use InlineKeyboardMarkup and a CallbackQueryHandler, and always call query.answer(). Our post on inline keyboards and callback queries has a complete example, and the keyboard builder designs the layout.

6. Error handling and persistence

Register one error handler so exceptions are logged instead of disappearing:

async def on_error(update, context):
    logging.exception("Update caused an error", exc_info=context.error)

app.add_error_handler(on_error)

context.user_data and context.chat_data live in memory and vanish on restart. For small bots, PicklePersistence stores them in a file:

from telegram.ext import PicklePersistence
persistence = PicklePersistence(filepath="data/bot.pickle")
app = Application.builder().token(TOKEN).persistence(persistence).build()

Mark conversations with persistent=True, name="signup" to keep their state too. For anything bigger, use a real database; see databases and state for bots.

7. Scheduled jobs

With the job-queue extra, context.job_queue schedules callbacks:

async def remind(context):
    await context.bot.send_message(context.job.chat_id, f"Reminder: {context.job.data}")

async def remind_cmd(update, context):
    minutes = int(context.args[0]); text = " ".join(context.args[1:]) or "time is up"
    context.job_queue.run_once(remind, when=minutes * 60, chat_id=update.effective_chat.id, data=text)
    await update.message.reply_text(f"I will remind you in {minutes} min.")

Jobs live in memory, so they are lost on restart. Durable reminders need storage; see scheduled messages and reminders.

8. Deploy: polling or webhook

Polling (simplest): run python bot.py as an always-on process on a VPS or a worker service. run_polling handles SIGTERM for clean restarts. Containerize it with this Docker setup.

Webhook: needs a public HTTPS URL, but lets the host scale and sleep between requests.

app.run_webhook(
    listen="0.0.0.0",
    port=int(os.environ.get("PORT", 8080)),
    url_path="telegram",
    webhook_url="https://your-app.example.com/telegram",
    secret_token=os.environ["WEBHOOK_SECRET"],
)

run_webhook registers the webhook with Telegram and checks the secret header for you. Background in Telegram setWebhook step by step; hosting options in best hosting for Telegram bots and deploying a Python bot.

Checklist before going live

  • Token in an environment variable, httpx logging at WARNING.
  • Error handler registered; logs visible on the host.
  • Persistence or a database for anything users expect to survive a restart.
  • One running instance for polling.
  • Respect Telegram's sending limits for broadcasts; see rate limits explained.

Sources

Related comparisons and tools

Frequently asked questions

python-telegram-bot or aiogram?

Both are solid async Python libraries. python-telegram-bot has extensive docs and built-in conversation and job helpers; aiogram is built on aiohttp with a router and middleware design. Pick the one whose docs you find clearer.

Why does my bot stop answering after I deploy a second copy?

Only one process may call getUpdates for a token. Two polling instances conflict and Telegram returns 409 Conflict to one of them. Run a single instance or switch to a webhook.