NEW 📢 Limited launch offer: up to 70% off your first month on every game!See plans

How to use a MySQL database with a python-telegram-bot bot (aiomysql)

Telegram bot hostingUpdated Nov 6, 202612 min read

Short answer. Add aiomysql to your python-telegram-bot bot, open one pool with autocommit=True in post_init, keep it in application.bot_data so every handler can reach it, and close it in post_shutdown. Use %s placeholders and store Telegram IDs as BIGINT, not INT. On PlayNHost every bot plan includes one MySQL database: create it on the Databases page, paste its details into the Environment pane, and the code below works as it is. The database accepts connections from your PlayNHost servers and phpMyAdmin, not from your own computer. We tested this code on 6 November 2026 with python-telegram-bot 22.8, aiomysql 0.3.2 and Python 3.12, against MariaDB 10.11 with a database account set up like ours (20 connections, 120-second query limit) and a stand-in for Telegram's Bot API server: 8 commands in, 8 answers out, the notes saved in the database. A real Telegram login was not part of the test.

What you need

1. Create the database

  1. Open your bot server in the panel and click Databases.
  2. Click New database. A Label is optional; Will be created as shows the full name. Click Create database.
  3. The new database's page opens (later, click it in Your databases). Its Connection card shows Host, Port, Database, Username and Password. Under Ready-made config, Config for has a Python (aiomysql) snippet with the same values filled in.

2. Put the details in Environment

  1. Open the Bot page, pane Environment, and click Paste a .env file.
  2. Paste these five lines with the values from the Connection card:
    DB_HOST=your-host
    DB_PORT=3306
    DB_NAME=your-database
    DB_USER=your-username
    DB_PASSWORD=your-password
  3. DB_PASSWORD gets a Secret badge: it is saved write-only and never shown again. Click Import 5 variables.

Use the real port from the Connection card. The bot token is not one of these lines: the token you saved on the Bot page, pane Telegram, already reaches your code as TELEGRAM_BOT_TOKEN, the name the code below reads (and as BOT_TOKEN, TOKEN, TELEGRAM_TOKEN and TELOXIDE_TOKEN). If your .env file has a token line, the dialog skips it. More on this pane: How to set environment variables and secrets.

3. The code

A small notes bot: /note buy milk saves a note, /notes shows your last five.

requirements.txt

python-telegram-bot>=22,<23
aiomysql

bot.py

import os

import aiomysql
from telegram import Update
from telegram.ext import Application, CommandHandler, ContextTypes


async def post_init(application: Application) -> None:
    # One pool for the whole bot. The values come from the Environment pane.
    pool = await aiomysql.create_pool(
        host=os.environ["DB_HOST"],
        port=int(os.environ["DB_PORT"]),
        user=os.environ["DB_USER"],
        password=os.environ["DB_PASSWORD"],
        db=os.environ["DB_NAME"],
        autocommit=True,
        maxsize=5,
    )
    async with pool.acquire() as conn:
        async with conn.cursor() as cur:
            await cur.execute(
                "CREATE TABLE IF NOT EXISTS notes ("
                "id INT AUTO_INCREMENT PRIMARY KEY, "
                "user_id BIGINT NOT NULL, "
                "text VARCHAR(500) NOT NULL, "
                "created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, "
                "KEY user_id (user_id))"
            )
    application.bot_data["pool"] = pool
    print("Database ready")
    print(f"Logged in as @{application.bot.username}")


async def post_shutdown(application: Application) -> None:
    pool = application.bot_data.get("pool")
    if pool is not None:
        pool.close()
        await pool.wait_closed()


async def note(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
    text = " ".join(context.args).strip()
    if not text:
        await update.effective_message.reply_text("Write your note after the command, for example: /note buy milk")
        return
    if len(text) > 500:
        await update.effective_message.reply_text("A note can be up to 500 characters.")
        return
    pool = context.bot_data["pool"]
    async with pool.acquire() as conn:
        async with conn.cursor() as cur:
            await cur.execute(
                "INSERT INTO notes (user_id, text) VALUES (%s, %s)",
                (update.effective_user.id, text),
            )
    await update.effective_message.reply_text("Saved. Send /notes to see your last five.")


async def notes(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
    pool = context.bot_data["pool"]
    async with pool.acquire() as conn:
        async with conn.cursor() as cur:
            await cur.execute(
                "SELECT text FROM notes WHERE user_id = %s ORDER BY id DESC LIMIT 5",
                (update.effective_user.id,),
            )
            rows = await cur.fetchall()
    if not rows:
        await update.effective_message.reply_text("You have no notes yet.")
        return
    await update.effective_message.reply_text("\n".join(f"- {text}" for (text,) in rows))


def main() -> None:
    application = (
        Application.builder()
        .token(os.environ["TELEGRAM_BOT_TOKEN"])
        .post_init(post_init)
        .post_shutdown(post_shutdown)
        .build()
    )
    application.add_handler(CommandHandler("note", note))
    application.add_handler(CommandHandler("notes", notes))
    application.run_polling()


if __name__ == "__main__":
    main()

What it does: run_polling() first asks Telegram who the bot is, then runs post_init, and only then starts fetching messages. post_init opens the pool, creates the notes table if it is missing, puts the pool in application.bot_data["pool"] and prints Database ready and Logged in as @your_bot. Every handler gets the same dictionary as context.bot_data. When the bot stops, run_polling() runs post_shutdown, which closes the pool.

context.args is the text after the command, split into words, so the code joins it back with single spaces: line breaks and double spaces inside a note become one space. If you need the note exactly as typed, take update.effective_message.text and cut off the command yourself.

The %s placeholders are filled in by the driver, so a note such as '); DROP TABLE notes; -- is stored as plain text. In our test it was saved as row 2 and the table stayed. Never build the SQL with an f-string.

Store Telegram IDs as BIGINT

Telegram's Bot API documentation says a user or chat ID "may have more than 32 significant bits" but has at most 52, so a 64-bit integer is safe. Its page on IDs gives the ranges: user IDs go up to 1,099,511,627,775, while group, supergroup and channel IDs are negative. Supergroups and channels run from -1,000,000,000,001 to -1,997,852,516,352 (for example -1001234567890). MySQL's INT stops at 2,147,483,647. In our test, with the same strict SQL mode our database server uses, saving the user ID 7,000,000,001 in an INT column failed. aiomysql raised:

pymysql.err.DataError: (1264, "Out of range value for column 'user_id' at row 1")

A BIGINT column stored it, and -1001234567890 too. Use plain BIGINT, not BIGINT UNSIGNED, so chat IDs fit as well.

4. Run it

  1. Upload bot.py and requirements.txt in Files.
  2. Press Start and open Console. The start installs the packages from requirements.txt, then shows [PlayNHost] Starting your bot, Database ready and Logged in as @your_bot. That last line turns the status on Overview to Online in Telegram.
  3. In Telegram, send your bot /note buy milk, then /notes. To look at the rows, open the database's page and click Open in phpMyAdmin.

From the second start on, the console also shows Warning: Table 'notes' already exists, with the path of aiomysql's cursors.py in front of it. That is MariaDB's note for CREATE TABLE IF NOT EXISTS, passed on by the driver. Nothing is wrong.

In our test the stand-in for Telegram sent these commands and got these answers:

SentAnswer
/note buy milkSaved. Send /notes to see your last five.
/noteWrite your note after the command, for example: /note buy milk
/note and 501 charactersA note can be up to 500 characters.
/notesThe saved notes, newest first
/notes from a second userYou have no notes yet.

One update at a time

With its default settings, python-telegram-bot handles one update at a time: the next command waits until the previous handler has finished. In our test, 4 commands that arrived together were answered in the order they arrived, and after the test the bot held 1 open connection. maxsize=5 still matters if you turn on concurrent_updates in Application.builder(), or if jobs and handlers query at the same time: the pool never opens more than 5 connections, and an extra query waits for a free one.

The autocommit trap

aiomysql opens connections with autocommit off. An INSERT then stays inside an open transaction. When the connection goes back to the pool with that transaction still open, aiomysql closes it, and MariaDB throws the uncommitted row away. In our test, a row inserted on a pool without autocommit=True was seen by 0 other connections. Either pass autocommit=True to create_pool, as above, or call await conn.commit() after your writes.

A failed query does not stop the bot

In our test we deleted the table while the bot was running and sent /notes. python-telegram-bot printed No error handlers are registered, logging exception. and a traceback ending in pymysql.err.ProgrammingError: (1146, "Table '...notes' doesn't exist"), sent no answer for that command, and kept running: once the table was back, the next /note was saved. The person who sent the command sees nothing, so catch the error in the handler, or register a handler with application.add_error_handler(), and answer with a short message.

A quiet night does not break the pool

Our database server closes a connection that has been idle for 8 hours. A bot that gets no messages for that long, overnight for example, finds its pooled connections closed. aiomysql checks for that when a handler asks for a connection and opens a new one in place of a closed one. We tested it with the idle limit lowered to 5 seconds: after 7 quiet seconds, the next /note was saved with no error.

Limits that matter for your pool

  • 20 connections at once per database user. Every process that uses the same login counts. Connection 21 is refused with (1226, "User '...' has exceeded the 'max_user_connections' resource (current value: 20)"). maxsize=5 keeps the bot well under it.
  • A query that runs longer than 120 seconds is stopped. MariaDB ends it with error 1969, Query execution was interrupted (max_statement_time exceeded). In an earlier test of ours, on an account set up the same way, that came after 120.03 seconds. A bot's queries normally take milliseconds, so this only bites a runaway query.
  • 500 MB per database. The Databases page shows how much is used. At 500 MB, writes are stopped until you delete rows.
  • Server backups do not include the database. Export it from phpMyAdmin from time to time.

Errors and what they mean

These are the errors aiomysql gave in our test, written the way they end a traceback in the console (they come from PyMySQL, which aiomysql uses underneath):

  • pymysql.err.OperationalError: (1045, "Access denied for user ..."): wrong DB_USER or DB_PASSWORD. After Reset password on the Connection card the old password stops working at once: replace DB_PASSWORD and restart the bot.
  • pymysql.err.OperationalError: (2003, "Can't connect to MySQL server on ..."): wrong host or port, or a connection from outside PlayNHost. The database only accepts connections from PlayNHost servers and phpMyAdmin, so this is also what you get on your own computer.
  • pymysql.err.OperationalError: (1226, ... 'max_user_connections' ...): too many connections open, see the limits above. For example a second copy of the bot, or a pool opened for each message.
  • pymysql.err.DataError: (1264, "Out of range value for column 'user_id' at row 1"): an ID column is INT. Change it to BIGINT.
  • pymysql.err.ProgrammingError: (1146, "Table '...' doesn't exist"): the table is missing. The code above creates it at start; if you made it by hand, check its name.

A variable missing from Environment stops the bot before it connects, with KeyError: 'DB_HOST' or the name of the missing one, because the code reads each value with os.environ[...]. Add it, then restart the bot: variables apply the next time it starts.

A wrong token stops the bot before the database part, because post_init only runs after Telegram has accepted the token. With our fake token against the real Telegram, the bot never printed Database ready and stopped after about 1 second with telegram.error.InvalidToken: The token `...` was rejected by the server. That message contains the whole token, so do not paste console output with it anywhere public. Copy the token again from @BotFather and paste it on the Bot page, pane Telegram. More Telegram errors: Fix Telegram bot 409 Conflict.

How much memory it uses

In our test the running bot used 48.5 MB of memory (resident) after answering the 8 commands, with its pool open, in a container capped at 512 MB like our smallest bot plan. In our earlier hosting test, python-telegram-bot alone measured 44 MB (that bot stopped at a refused token, before handling any message), so the difference, about 4.5 MB, covers aiomysql, the pool and the handled commands. Installing python-telegram-bot and aiomysql with pip from nothing peaked at 73 MB, file cache included. The libraries side by side: python-telegram-bot vs aiogram on PlayNHost.

Same thing with another library

Need a server for your Telegram bot with a database included? See the Telegram bot plans.

Already hosting your bot somewhere else? Move it to us

OUR LATEST BLOGS