База данных в телеграм-боте: SQLite от первого лица
Даём боту настоящую память: таблица пользователей с chat_id, запись через INSERT OR IGNORE, чтение по chat_id и параметр ? против SQL-инъекций.
Редакция Питоники
У вашего бота уже есть кнопки, FSM-диалоги и медиа — но память золотой рыбки. Пользователь нажал /start, бот его поприветствовал, процесс перезапустился — и бот уже не помнит, что этого человека когда-то видел. Счётчики, подписки, балансы, анкеты — всё это требует хранилища, которое переживёт перезапуск. Сегодня подключаем такое: SQLite, база данных, которая живёт прямо внутри Python.
И вот что приятно: из всех уроков курса этот — самый честный по коду. sqlite3 входит в стандартную библиотеку и работает в браузерной песочнице по-настоящему: каждая таблица ниже создаётся, наполняется и опрашивается прямо на странице. Никаких имитаций — только настоящий SQL.
Почему словарь — не база данных
Первое, что приходит в голову: «зачем база, у меня же есть словарь». Посмотрим, как словарная «база» переживает перезапуск бота:
# Бот на словаре: данные живы, пока жив процесс
users = {} # "база" в оперативной памяти
def register(chat_id, name):
users[chat_id] = name
print("Зарегистрировал:", name)
register(101, "Аня")
register(102, "Борис")
print("Бот работает, в памяти:", users)
print("...бот перезапустился...")
users = {} # процесс умер, вместе с ним умер словарь
print("После перезапуска:", users)
Зарегистрировал: Аня
Зарегистрировал: Борис
Бот работает, в памяти: {101: 'Аня', 102: 'Борис'}
...бот перезапустился...
После перезапуска: {}Словарь живёт в оперативной памяти процесса. Процесс упал или вы нажали Ctrl+C, чтобы залить новую версию кода, — память освободилась, все пользователи испарились. SQLite устроен иначе: это отдельный файл на диске, к которому Python обращается через модуль sqlite3. Сервер ставить не нужно — движок базы встроен в сам Python, поэтому import sqlite3 работает везде: на вашем ноутбуке, на VPS и даже в браузерном интерпретаторе этой страницы.
- переживает перезапуск: данные лежат в файле на диске, а не в памяти процесса;
- ищет по ключу мгновенно: у таблиц есть индексы, словарь из миллиона записей вы фильтруете перебором;
- хранит целостность: повторный chat_id не создаст дубликат, если вы этого не разрешите;
- умеет SQL: отчёты, сортировки, агрегаты — одной строкой вместо трёх вложенных циклов.
Первая таблица: пользователи бота
Начнём с главной таблицы любого бота — пользователи. У Telegram есть готовый уникальный номер чата chat_id: он не меняется и идеально подходит на роль первичного ключа. Добавим к нему имя и дату знакомства. Следующий блок выполняется в песочнице по-настоящему: прямо сейчас рождается база с двумя пользователями.
import sqlite3
con = sqlite3.connect(":memory:") # база в оперативной памяти
cur = con.cursor()
cur.execute("""
CREATE TABLE users (
chat_id INTEGER PRIMARY KEY,
name TEXT,
joined TEXT
)
""")
cur.execute("INSERT INTO users VALUES (?, ?, ?)", (101, "Аня", "март"))
cur.execute("INSERT INTO users VALUES (?, ?, ?)", (102, "Борис", "март"))
con.commit()
cur.execute("SELECT chat_id, name FROM users")
for chat_id, name in cur.fetchall():
print(chat_id, name)
con.close()
101 Аня 102 Борис
Разберём по строкам. sqlite3.connect() открывает соединение с базой: строка ":memory:" означает «держи базу в памяти» — удобно для опытов; в настоящем боте вы напишете sqlite3.connect("bot.db"), и рядом со скриптом появится файл базы. con.cursor() возвращает курсор — инструмент, через который уходят запросы. CREATE TABLE задаёт колонки, а INTEGER PRIMARY KEY обещает: chat_id уникален и искать по нему база будет по индексу. Знаки ? на месте значений — это параметры, о них чуть ниже, они важнее, чем выглядят. И con.commit() — подтверждение записи: без него INSERT останется только в вашем соединении.
SELECT, UPDATE и None: работа с профилем
Чтение и обновление — это те же execute с другими словами запроса. Бот будет делать это постоянно: показать профиль, сменить имя, проверить, знаком ли он с человеком. Запустите — база с пользователями родится заново, и пройдём по ней весь цикл:
import sqlite3
con = sqlite3.connect(":memory:")
con.execute("CREATE TABLE users (chat_id INTEGER PRIMARY KEY, name TEXT)")
con.execute("INSERT INTO users VALUES (?, ?)", (101, "Аня"))
con.execute("INSERT INTO users VALUES (?, ?)", (102, "Борис"))
con.commit()
# SELECT одной строки по chat_id
cur = con.execute("SELECT name FROM users WHERE chat_id = ?", (101,))
print("Профиль 101:", cur.fetchone()[0])
# UPDATE: пользователь сменил имя
con.execute("UPDATE users SET name = ? WHERE chat_id = ?", ("Аня Смирнова", 101))
con.commit()
cur = con.execute("SELECT name FROM users WHERE chat_id = ?", (101,))
print("После UPDATE:", cur.fetchone()[0])
# Несуществующий chat_id: fetchone вернёт None
cur = con.execute("SELECT name FROM users WHERE chat_id = ?", (999,))
print("Профиль 999:", cur.fetchone())
con.close()
Профиль 101: Аня После UPDATE: Аня Смирнова Профиль 999: None
Третий вывод — самый важный для бота. fetchone() вернул None: пользователя с chat_id 999 в базе нет. Это стандартный сигнал в коде бота: получи профиль, и если None — попроси человека нажать /start. Заметьте и удобство: con.execute(...) можно вызывать прямо на соединении, без отдельного курсора, — sqlite3 разрешает оба стиля.
| SQL | Задача бота | Запрос |
|---|---|---|
| INSERT | Зарегистрировать нового | INSERT INTO users VALUES (?, ?, ?) |
| SELECT | Показать профиль, проверку знакомства | SELECT name FROM users WHERE chat_id = ? |
| UPDATE | Сменить имя, пополнить баланс | UPDATE users SET name = ? WHERE chat_id = ? |
| DELETE | Отписка, удалить анкету | DELETE FROM users WHERE chat_id = ? |
Мини-отчёт: GROUP BY и ORDER BY
Настоящий бот не только записывает, но и отчитывается: топ слов, сумма трат за неделю, самые активные пользователи. Всё это — обычный SELECT с агрегатами, без единого цикла в коде бота. Представим, что наш бот собирает слова пользователей и раз в день хвастается топ-3. Запустите — этот отчёт считается прямо сейчас:
import sqlite3
con = sqlite3.connect(":memory:")
con.execute("CREATE TABLE words (chat_id INTEGER, word TEXT, n INTEGER)")
rows = [
(101, "кошка", 5),
(101, "собака", 3),
(101, "кошка", 2),
(102, "собака", 4),
(102, "мышка", 1),
]
con.executemany("INSERT INTO words VALUES (?, ?, ?)", rows)
con.commit()
cur = con.execute("""
SELECT word, SUM(n) AS total
FROM words
WHERE chat_id = ?
GROUP BY word
ORDER BY total DESC
LIMIT 3
""", (101,))
for word, total in cur.fetchall():
print(word, "-", total)
con.close()
кошка - 7 собака - 3
Разбор по строчкам. GROUP BY word склеивает одинаковые слова в группы, SUM(n) суммирует счётчики внутри группы — две записи про кошку сложились в семь. ORDER BY total DESC ставит самое частое слово наверх, а LIMIT 3 страхует чат от простыни: пользователей у бота будут тысячи. Обратите внимание и на executemany: он вставляет список кортежей одним вызовом — те же ?, просто в цикле внутри драйвера. Именно из таких запросов состоит финальный проект курса: трекер расходов считает траты за месяц одним SELECT с SUM, а не перебором словарей в цикле.
Есть и вторая причина завести базу, менее очевидная, чем перезапуск. Когда данных становится много, несколько функций бота начинают делить один словарь, и очень скоро непонятно, кто и когда его менял. В базе каждая правка — зафиксированная транзакция, а данные можно открыть глазами — тем же DB Browser — и увидеть их такими, какие они есть, без посредника в лице десятка print().
Что такое SQL-инъекция и как параметр ? от неё спасает
Пользователь шлёт боту любые строки — и эти строки рано или поздно оказываются рядом с SQL. Скажем, вы ищете человека по имени и собираете запрос склейкой: "SELECT ... WHERE name = '" + name + "'". Что если в поле «имя» пришло x' OR '1'='1? Соберите такой запрос и посмотрите, что вернёт база — это можно сделать прямо здесь:
import sqlite3
con = sqlite3.connect(":memory:")
con.execute("CREATE TABLE users (chat_id INTEGER PRIMARY KEY, name TEXT)")
con.execute("INSERT INTO users VALUES (?, ?)", (1, "Аня"))
con.execute("INSERT INTO users VALUES (?, ?)", (2, "Борис"))
con.commit()
# Пользователь прислал вместо имени готовый фрагмент SQL
evil = "x' OR '1'='1"
# Наивная склейка строк: фрагмент стал частью запроса
naive = "SELECT chat_id FROM users WHERE name = '" + evil + "'"
rows = con.execute(naive).fetchall()
print("Склейка строк вернула строк:", len(rows))
# Параметр ? : строка осталась просто строкой-значением
rows = con.execute(
"SELECT chat_id FROM users WHERE name = ?", (evil,)
).fetchall()
print("С параметром ? вернулось строк:", len(rows))
con.close()
Склейка строк вернула строк: 2 С параметром ? вернулось строк: 0
Разбор. В склеенном запросе кавычка из строки пользователя закрыла вашу кавычку, дальше подставился оператор OR, а условие '1'='1' истинно всегда — база вернула обоих пользователей вместо нуля. В учебной таблице это шутка, в таблице балансов — способ обнулить чужой счёт. Параметр ? никогда не становится частью запроса: он всегда просто значение, даже если внутри спрятан кусок SQL. Драйвер sqlite3 экранирует кавычки сам, и evil ищется в базе как буквально такое странное имя — а такого имени нет, ноль строк.
Куда ставить commit, чтобы данные не пропали
sqlite3 не пишет в файл на каждый INSERT — он копит изменения и ждёт con.commit(). Это продуманное поведение: несколько связанных записей уезжают в базу одной пачкой — транзакцией, по принципу «всё или ничего». Ручные вызовы commit работают, но есть аккуратнее приём — контекстный менеджер with con:, который подтвердит записи при выходе без ошибок и откатит их, если в середине что-то упало:
import sqlite3
con = sqlite3.connect(":memory:")
con.execute("CREATE TABLE stats (chat_id INTEGER, word TEXT)")
# with у sqlite3 - это транзакция: вышли без ошибок - данные сохранены
with con:
con.execute("INSERT INTO stats VALUES (?, ?)", (101, "кошка"))
con.execute("INSERT INTO stats VALUES (?, ?)", (101, "собака"))
n = con.execute("SELECT COUNT(*) FROM stats").fetchone()[0]
print("Сохранено строк:", n)
# а здесь транзакция упала - и ни одна из строк не записалась
try:
with con:
con.execute("INSERT INTO stats VALUES (?, ?)", (102, "мышка"))
raise ValueError("хендлер упал посреди записи")
except ValueError as e:
print("Откат:", e)
n = con.execute("SELECT COUNT(*) FROM stats").fetchone()[0]
print("После отката строк:", n)
con.close()
Сохранено строк: 2 Откат: хендлер упал посреди записи После отката строк: 2
Смотрите, как честно отработала транзакция: слова «мышка» в базе нет, хотя INSERT был выполнен. Упавшая транзакция откатывает всю пачку — база не остаётся в полузаписанном состоянии. В боте это спасает от рассинхрона: либо пользователь оформлен и получил приветствие, либо нет ни того, ни другого.
Как хендлер /start сохраняет пользователя
Соберём всё в главный ритуал бота с базой — обработку /start. Логика: вставить пользователя, но не упасть, если он уже есть. Для этого у SQL есть INSERT OR IGNORE: новая строка вставляется, а дубль по первичному ключу тихо пропускается. По cur.rowcount видно, что именно произошло: 1 — добавили, 0 — человек уже был. Сначала — тренажёр на чистом Python, он выполняется здесь же:
import sqlite3
con = sqlite3.connect(":memory:")
con.execute("CREATE TABLE users (chat_id INTEGER PRIMARY KEY, name TEXT)")
def handle_start(chat_id, name):
# это то, что делает хендлер /start в настоящем боте
cur = con.execute(
"INSERT OR IGNORE INTO users (chat_id, name) VALUES (?, ?)",
(chat_id, name),
)
if cur.rowcount == 1:
print("Новый пользователь:", name, "- добавлен в базу")
else:
print("Уже знакомы:", name, "- пропустили")
handle_start(101, "Аня")
handle_start(102, "Борис")
handle_start(101, "Аня") # Аня нажала /start ещё раз
handle_start(103, "Вика")
total = con.execute("SELECT COUNT(*) FROM users").fetchone()[0]
print("Пользователей в базе:", total)
con.close()
Новый пользователь: Аня - добавлен в базу Новый пользователь: Борис - добавлен в базу Уже знакомы: Аня - пропустили Новый пользователь: Вика - добавлен в базу Пользователей в базе: 3
Тренажёр — это ровно та логика, которая живёт внутри настоящего хендлера. Вот он целиком, уже на aiogram — такой файл запускается на вашем компьютере с токеном из первого урока:
import sqlite3
from datetime import datetime
from aiogram import Router
from aiogram.filters import CommandStart
from aiogram.types import Message
router = Router()
def db() -> sqlite3.Connection:
con = sqlite3.connect("bot.db") # файл появится рядом со скриптом
con.execute("""
CREATE TABLE IF NOT EXISTS users (
chat_id INTEGER PRIMARY KEY,
name TEXT,
joined TEXT
)
""")
return con
@router.message(CommandStart())
async def cmd_start(message: Message) -> None:
with db() as con:
con.execute(
"INSERT OR IGNORE INTO users (chat_id, name, joined) VALUES (?, ?, ?)",
(
message.chat.id,
message.from_user.first_name or "без имени",
datetime.now().isoformat(timespec="seconds"),
),
)
await message.answer("Привет! Я запомнил тебя - заходи.")
И дополнение к нему — команда /profile, которая читает профиль и показывает, зачем вообще была нужна база. Обратите внимание на sqlite3.Row: он превращает строки результата в объекты, доступные по имени колонки, — читается куда лучше, чем row[1]:
@router.message(Command("profile"))
async def cmd_profile(message: Message) -> None:
with db() as con:
con.row_factory = sqlite3.Row
row = con.execute(
"SELECT name, joined FROM users WHERE chat_id = ?",
(message.chat.id,),
).fetchone()
if row is None:
await message.answer("Тебя нет в базе - нажми /start")
return
await message.answer(f"{row['name']}, ты с нами с {row['joined']}")
Что дальше: у бота есть память
Теперь бот переживает перезапуск: пользователи, их слова и балансы лежат в файле базы, регистрация идёт через /start с INSERT OR IGNORE, а кривые строки пользователей не пробивают запросы благодаря ?. Словарь из первого примера урока теперь годится разве что для кэша — всё долговечное переезжает в bot.db. Эти же навыки переносятся на веб: в уроке про SQLite во FastAPI та же база обслуживает HTTP-API, только там к ней добавляется SQLAlchemy. А в финальном проекте-трекере расходов база станет главным органом бота: кнопки, FSM и отчёты будут читать и писать в неё постоянно.
SQLite — единственная база данных, которая переезжает вместе с ботом одним файлом: скопировал bot.db — перенёс всех пользователей.
Сначала предскажи ответ в голове — это главный навык программиста.
import sqlite3
con = sqlite3.connect(":memory:")
con.execute("CREATE TABLE t (id INTEGER PRIMARY KEY, name TEXT)")
con.execute("INSERT INTO t VALUES (?, ?)", (1, "Аня"))
con.commit()
print(con.execute("SELECT name FROM t WHERE id = ?", (1,)).fetchone()[0])
print(con.execute("SELECT name FROM t WHERE id = ?", (2,)).fetchone())
import sqlite3
con = sqlite3.connect(":memory:")
con.execute("CREATE TABLE u (id INTEGER PRIMARY KEY, tag TEXT)")
cur = con.execute("INSERT OR IGNORE INTO u VALUES (?, ?)", (7, "тест"))
print(cur.rowcount)
cur = con.execute("INSERT OR IGNORE INTO u VALUES (?, ?)", (7, "тест"))
print(cur.rowcount)
import sqlite3
con = sqlite3.connect(":memory:")
con.execute("CREATE TABLE t (n INTEGER)")
try:
with con:
con.execute("INSERT INTO t VALUES (?)", (1,))
raise ValueError("упс")
except ValueError:
pass
print(con.execute("SELECT COUNT(*) FROM t").fetchone()[0])
1. Почему для пользователей бота не годится обычный словарь?
2. Что делает INSERT OR IGNORE в хендлере /start?
3. Почему значения в SQL-запросах подставляют через ?, а не через f-строку?
4. Что вернёт fetchone(), если пользователя с таким chat_id нет в базе?
5. Что делает конструкция with con: у соединения sqlite3?
Напишите хендлер /start на тренажёре: функция handle_start(chat_id, name) должна вставлять пользователя через INSERT OR IGNORE и печатать «Новый: имя», если строка добавлена, или «Уже в базе: имя», если такой chat_id уже был. В конце выведите общее число пользователей.
Нужна ли телеграм-боту настоящая база данных или хватит файла с данными?
sqlite3 — это и есть файл с данными, только с движком SQL внутри Python: не нужно ставить сервер, а записи защищены от дублей и потерь транзакциями. JSON-словарь в файле при параллельных записях рассинхронизируется и не умеет искать по ключу — на сотне пользователей это уже больно.
Как хранить пользователей телеграм-бота в SQLite?
Таблица users с колонками chat_id INTEGER PRIMARY KEY, name, joined. В хендлере /start выполняется INSERT OR IGNORE: новый пользователь записывается, повторное нажатие не создаёт дубль. Профиль читается SELECT'ом по chat_id — он известен из message.chat.id каждого входящего сообщения.
Что такое SQL-инъекция применительно к телеграм-боту?
Пользователь шлёт боту произвольный текст, и если этот текст склеить с SQL-запросом, он может дописать в запрос свой фрагмент: закрыть кавычку и добавить OR '1'='1', чтобы получить чужие данные. Защита — параметризованные запросы: con.execute("... WHERE name = ?", (name,)), где значение всегда остаётся значением.
Где бот хранит базу SQLite и как на неё посмотреть?
В файле рядом со скриптом: sqlite3.connect("bot.db") создаёт его автоматически при первом запуске. Открыть файл глазами можно бесплатным DB Browser for SQLite — там видно таблицы, строки и схемы запросов. Для тестов и песочницы используют sqlite3.connect(":memory:") — база в оперативной памяти.
Понравился урок? Сошлитесь на него
«Параметр ? никогда не становится частью запроса: он всегда просто значение, даже если внутри спрятан кусок SQL.»
Скопируйте готовую ссылку в формате HTML, Markdown или чистый адрес и вставьте в статью на Habr, VC, Telegram-канал или свой блог — так о проекте узнают новые читатели.
Что читать дальше
FastAPI · Урок 7
Подключаем базу данных SQLite к FastAPI
Словарь задач из урока 5 умирает при перезапуске. Ставим на его место SQLite через SQLAlchemy: движок, сессии, Depends и CRUD, который переживает uvicorn --reload.
aiogram · Урок 10
Проект: телеграм-бот трекер расходов на aiogram с базой данных
Сквозной проект курса: бот-трекер расходов с inline-кнопками, FSM-диалогом, SQLite-хранилищем и отчётом за месяц. Рабочая версия в песочнице, полный код на aiogram и чек-лист запуска.
aiogram · Урок 1
Как создать телеграм-бота на Python: от BotFather до первого эхо-ответа
Регистрируем бота в BotFather, разбираемся, что такое токен и long polling, и собираем эхо-бота на aiogram — механику проверяем прямо в браузере, без токена.
Похожие уроки по темам
Подобраны автоматически по пересечению тем и ключевых слов.
Flask · Урок 5
База данных в Flask: SQLite и Flask-SQLAlchemy
Пятый урок курса Flask: данные переживают перезапуск сервера. Подключаем SQLite, описываем модели Flask-SQLAlchemy, проходим CRUD и собираем гостевую книгу, которая помнит всех гостей.
flask и база данных sqlalchemysqlalchemy sqlite
sqlite3 · Урок 1
SQL с нуля через Python: первая база SQLite и таблица
Первая настоящая база данных без установки серверов: import sqlite3, курсор, CREATE TABLE и первые строки — всё исполняется прямо на странице.
sql для начинающихsqlite python
aiogram · Урок 3
Клавиатуры в телеграм-боте: reply и inline кнопки в aiogram
Строим кнопки, которыми приятно пользоваться: ReplyKeyboardMarkup против InlineKeyboardMarkup, ряды, callback_data и обработка нажатий в aiogram 3.
inline кнопки aiogramклавиатура телеграм бота