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

How to use a MySQL database with a grammY bot (mysql2)

Telegram bot hostingUpdated Nov 6, 202611 min read

Short answer. Add mysql2 to your grammY bot, create one connection pool from environment variables when the bot starts, create your tables before bot.start(), and run every query through the pool. Use ? 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 grammY 1.46.0, mysql2 3.24.6 and Node.js 22, 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 Node.js (mysql2) 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.

package.json

{
  "name": "my-grammy-bot",
  "private": true,
  "main": "index.js",
  "dependencies": {
    "grammy": "^1.46.0",
    "mysql2": "^3.24.6"
  }
}

index.js

const { Bot } = require("grammy");
const mysql = require("mysql2/promise");

// One pool for the whole bot. The values come from the Environment pane.
const pool = mysql.createPool({
  host: process.env.DB_HOST,
  port: Number(process.env.DB_PORT),
  database: process.env.DB_NAME,
  user: process.env.DB_USER,
  password: process.env.DB_PASSWORD,
  connectionLimit: 5,
});

const bot = new Bot(process.env.TELEGRAM_BOT_TOKEN);

bot.command("note", async (ctx) => {
  const text = ctx.match.trim();
  if (!text) return ctx.reply("Write your note after the command, for example: /note buy milk");
  if (text.length > 500) return ctx.reply("A note can be up to 500 characters.");
  await pool.execute("INSERT INTO notes (user_id, text) VALUES (?, ?)", [ctx.from.id, text]);
  await ctx.reply("Saved. Send /notes to see your last five.");
});

bot.command("notes", async (ctx) => {
  const [rows] = await pool.execute(
    "SELECT text FROM notes WHERE user_id = ? ORDER BY id DESC LIMIT 5",
    [ctx.from.id],
  );
  if (rows.length === 0) return ctx.reply("You have no notes yet.");
  await ctx.reply(rows.map((row) => `- ${row.text}`).join("\n"));
});

// Without this, an error in one command stops the whole bot.
bot.catch((err) => {
  console.error(`Error while handling update ${err.ctx.update.update_id}:`, err.error);
});

async function main() {
  await pool.query(
    "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))",
  );
  console.log("Database ready");
  // bot.start() only returns when the bot stops, so the ready line goes in onStart.
  await bot.start({ onStart: (me) => console.log(`Logged in as @${me.username}`) });
}

main().catch((err) => {
  console.error(err);
  process.exit(1);
});

What it does: main() creates the notes table if it is missing and prints Database ready before bot.start() asks Telegram for messages. The pool is created once, at the top of the file, and every command uses it: pool.execute() borrows a connection, runs the query and gives the connection back. ctx.match is the text after the command: grammY removes the spaces in front of it and keeps the rest as typed, line breaks included. bot.catch() matters here: grammY's documentation says that without an error handler, an error in a handler stops a bot started with bot.start(), and a database error is exactly that kind of error.

The ? placeholders send the note to the database separately from the SQL, 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 a template 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. mysql2 gave the error code ER_WARN_DATA_OUT_OF_RANGE (errno 1264) with this message:

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 index.js and package.json in Files. Do not upload node_modules.
  2. Press Start and open Console. The start installs the packages from package.json, 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.

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.

Many messages at once

grammY's documentation says bot.start() processes all updates sequentially: one command finishes before the next one starts. In our test the bot held 1 open connection after answering the 8 commands. connectionLimit: 5 matters once your code runs queries in parallel, or if you move to grammY runner, which handles updates concurrently: the pool never opens more than 5 connections, and an extra query waits for a free one.

A failed query does not stop the bot

In our test we deleted the table while the bot was running and sent /notes. bot.catch() printed Error while handling update 500001: Error: Table '...notes' doesn't exist with code: 'ER_NO_SUCH_TABLE', the bot sent no answer for that command, and it kept running: once the table was back, the next /note was saved. The person who sent the command sees nothing, so if a query can fail, wrap it in try/catch in the 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. mysql2's pool deals with that by itself. 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 ER_USER_LIMIT_REACHED. In our test a pool with connectionLimit: 30 failed with that error when 30 queries ran at once. A pool of 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 error codes mysql2 gave in our test:

  • ER_ACCESS_DENIED_ERROR ... 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.
  • ECONNREFUSED: nothing accepted the connection at that host and port. Check DB_HOST and DB_PORT. A missing DB_HOST also ends here, because mysql2 then connects to localhost, where no database runs: the variable is missing from Environment, or the bot was not restarted after adding it.
  • ENOTFOUND: DB_HOST is misspelled, so the name does not exist.
  • ETIMEDOUT: nothing answered in time. Whatever the message, a connection from your own computer fails: the database only accepts connections from PlayNHost servers and phpMyAdmin.
  • ER_USER_LIMIT_REACHED ... has exceeded the 'max_user_connections' resource (current value: 20): too many connections open, see the limits above. For example a second copy of the bot, or a pool created for each message.
  • ER_WARN_DATA_OUT_OF_RANGE (errno 1264, Out of range value for column 'user_id' at row 1): an ID column is INT. Change it to BIGINT.
  • ER_NO_SUCH_TABLE (errno 1146): the table is missing. The code above creates it at start; if you made it by hand, check its name.

A wrong token shows up after the database part. With our fake token against the real Telegram, the bot printed Database ready, then stopped after about a quarter of a second with GrammyError: Call to 'deleteWebhook' failed! (401: Unauthorized). 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 67.6 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, grammY alone measured 60 MB (that bot stopped at a refused token, before handling any message), so the difference, about 8 MB, covers mysql2, the pool and the handled commands. Installing grammY and mysql2 with npm from nothing peaked at 140 MB, file cache included.

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