Перейти к содержанию

172 уроков, 13 библиотек и челлендж «Что выведет код?» — бесплатно, код прямо в браузере

Начать обучение
Урок 6 из 10 Средний 40 мин 130 XP

База данных в телеграм-боте: 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: он не меняется и идеально подходит на роль первичного ключа. Добавим к нему имя и дату знакомства. Следующий блок выполняется в песочнице по-настоящему: прямо сейчас рождается база с двумя пользователями.

CREATE TABLE и первые INSERT
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 с другими словами запроса. Бот будет делать это постоянно: показать профиль, сменить имя, проверить, знаком ли он с человеком. Запустите — база с пользователями родится заново, и пройдём по ней весь цикл:

SELECT по chat_id, UPDATE и честный None
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. Запустите — этот отчёт считается прямо сейчас:

топ слов пользователя: GROUP BY и ORDER BY
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:, который подтвердит записи при выходе без ошибок и откатит их, если в середине что-то упало:

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, он выполняется здесь же:

хендлер /start на тренажёре: INSERT OR IGNORE
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 — такой файл запускается на вашем компьютере с токеном из первого урока:

настоящий хендлер /start с базой (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("Привет! Я запомнил тебя - заходи.")
Настоящий aiogram-код: нужен токен и сеть, в песочнице не запускается. Логику INSERT OR IGNORE с rowcount вы только что запускали на тренажёре выше — она идентична.

И дополнение к нему — команда /profile, которая читает профиль и показывает, зачем вообще была нужна база. Обратите внимание на sqlite3.Row: он превращает строки результата в объекты, доступные по имени колонки, — читается куда лучше, чем row[1]:

команда /profile: SELECT по chat_id (aiogram)
@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']}")
Пример реального кода бота: выполняется локально с токеном. Схема None -> «нажми /start» повторяет проверку из тренажёра выше.

Что дальше: у бота есть память

Теперь бот переживает перезапуск: пользователи, их слова и балансы лежат в файле базы, регистрация идёт через /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])
Проверь себя
0 / 5

1. Почему для пользователей бота не годится обычный словарь?

2. Что делает INSERT OR IGNORE в хендлере /start?

3. Почему значения в SQL-запросах подставляют через ?, а не через f-строку?

4. Что вернёт fetchone(), если пользователя с таким chat_id нет в базе?

5. Что делает конструкция with con: у соединения sqlite3?

Карточки терминов
Запомнено: 0 / 6
Практика

Напишите хендлер /start на тренажёре: функция handle_start(chat_id, name) должна вставлять пользователя через INSERT OR IGNORE и печатать «Новый: имя», если строка добавлена, или «Уже в базе: имя», если такой chat_id уже был. В конце выведите общее число пользователей.

practice.py
Вопросы и ответы по уроку

Нужна ли телеграм-боту настоящая база данных или хватит файла с данными?

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-канал или свой блог — так о проекте узнают новые читатели.

TelegramVK

Похожие уроки по темам

Подобраны автоматически по пересечению тем и ключевых слов.