SQLite для бота

Даём «Цыплёнку-помощнику» настоящую память: маленький файл базы данных, который запоминает всех, кто нажал /start, и переживает любую перезагрузку бота.

SQLite — это база данных, которая живёт в одном-единственном файле на диске рядом с твоим ботом. Никакого отдельного сервера запускать не нужно: подключился к файлу, записал, прочитал — и всё. Идеально для небольшого бота, которому надо что-то помнить.

В прошлом уроке про то, зачем боту база данных мы поняли главную боль: пока данные лежат в обычной переменной Python, они исчезают при каждом перезапуске бота. Перезапустил bot.py — и словарь с пользователями пуст, будто их и не было. Сегодня мы это вылечим раз и навсегда: научим «Цыплёнка-помощника» хранить пользователей в SQLite, чтобы они никуда не девались.

Зачем боту своя записная книжка

Представь, что ты ведёшь бота для своего игрового клана. Каждый новый участник пишет /start, и бот радостно его приветствует. А теперь хочется большего: показать «ты у нас с 3 марта», поздравить с тем, что зашёл сотый человек, или разослать всем сообщение о турнире. Для всего этого боту нужно помнить, кто к нему вообще приходил.

Пока ты держишь список пользователей в переменной вроде users = [], всё работает — ровно до первого перезапуска. Обновил код, упал сервер, моргнуло электричество — и список снова пустой. Сто человек как корова языком слизала. Это как вести список друзей мелом на доске: стоит кому-то задеть доску рукавом, и записи нет.

База данных — это та же записная книжка, только её страницы не стираются. Ты пишешь карандашом в настоящий блокнот, который лежит в ящике стола. Закрыл блокнот, ушёл спать, вернулся через неделю — все записи на месте. SQLite и есть такой блокнот: один файл chick.db на диске, в котором аккуратными строчками лежат твои пользователи.

Вот к чему мы придём к концу урока. Бот, который при /start запоминает тебя в базе и считает, в первый ли раз ты пришёл:

@chick_helper_bot
🐤 Привет, Артём! Рад знакомству — занёс тебя в свою записную книжку.

# а если написать /start ещё раз:
🐤 С возвращением, Артём! Мы уже знакомы 😉

Результат: при первом /start бот сохранит пользователя в базу и поприветствует как новенького; при повторном — узнает его и поздоровается иначе. Даже если перезапустить бота между этими сообщениями, он всё равно вспомнит знакомого.

Что такое SQLite и почему она удобна для бота

Слово «база данных» звучит грозно: сразу представляется отдельный сервер, который надо установить, настроить, запустить, охранять. Так и есть для «взрослых» баз вроде PostgreSQL — это отдельная программа, которая постоянно крутится в фоне. Но SQLite устроена иначе и куда проще.

SQLite — это вся база данных, упакованная в один файл. Никакого сервера: библиотека sqlite3 уже встроена в Python, ничего доустанавливать не надо. Ты говоришь «открой файл chick.db», и если его нет — он создастся сам. Дальше работаешь с ним как с обычным файлом, только внутри — настоящие таблицы, по которым можно делать запросы.

Почему это идеальный выбор для небольшого бота:

  • Ничего не надо устанавливать. Модуль sqlite3 идёт вместе с Python из коробки.
  • Вся база — один файл. Хочешь сделать бэкап — просто скопируй chick.db на флешку. Хочешь перенести бота на другой компьютер — перенеси файл вместе с кодом.
  • Быстро для маленьких объёмов. Пока у тебя сотни и тысячи пользователей, SQLite справляется мгновенно.

Таблица в базе — это как лист в Excel: есть колонки (столбцы) с именами и строки с данными. Для пользователей нам хватит таблицы с колонками «id телеграма», «имя» и «когда впервые пришёл». Каждый новый пользователь — это новая строка.

Создаём таблицу пользователей

Прежде чем что-то записывать, надо объяснить базе, как будут выглядеть наши данные — то есть создать таблицу. Делается это языком SQL — специальным языком запросов к базам данных. Команда называется CREATE TABLE: «создай таблицу».

CREATE TABLE IF NOT EXISTS users (
    user_id    INTEGER PRIMARY KEY,
    name       TEXT,
    joined_at  TEXT
);

Разберём эту команду по косточкам:

  • CREATE TABLE — «создай таблицу»; следом идёт её имя users.
  • IF NOT EXISTS — «только если такой таблицы ещё нет». Без этой приписки бот при втором запуске упал бы с ошибкой «таблица уже существует». А так — создаст один раз и спокойно пропустит в следующие разы.
  • user_id INTEGER PRIMARY KEY — колонка для числового id пользователя из Telegram. INTEGER значит «целое число», а PRIMARY KEY — «это главный, уникальный ключ»: двух одинаковых user_id в таблице быть не может.
  • name TEXT — колонка для имени, тип TEXT (текст).
  • joined_at TEXT — когда пользователь впервые написал боту; дату удобно хранить просто строкой.

А теперь — как выполнить эту команду из Python. Подключаемся к файлу базы и просим её выполнить наш SQL:

import sqlite3

conn = sqlite3.connect("chick.db")
conn.execute(
    """
    CREATE TABLE IF NOT EXISTS users (
        user_id    INTEGER PRIMARY KEY,
        name       TEXT,
        joined_at  TEXT
    )
    """
)
conn.commit()
conn.close()

Результат: рядом с bot.py появится файл chick.db с пустой таблицей users внутри. Если запустить код ещё раз — благодаря IF NOT EXISTS ничего не сломается, таблица просто останется как была.

Три новых слова, которые важно понять сразу:

  1. sqlite3.connect("chick.db") — открывает (или создаёт) файл базы и возвращает соединение — это как открыть тот самый блокнот, чтобы в него писать.
  2. conn.execute("...") — выполняет SQL-команду, которую мы передали строкой.
  3. conn.commit()сохраняет изменения на диск. Это как нажать «Сохранить» в редакторе: пока не нажал, изменения висят в воздухе. Забудешь commit() — и записи не доедут до файла.
  4. conn.close() — закрывает блокнот, когда поработали.

INSERT: добавляем пользователя в базу

Таблица есть — пора её наполнять. За добавление строки отвечает SQL-команда INSERT: «вставь». Сначала посмотрим на чистый SQL, чтобы понять смысл:

INSERT INTO users (user_id, name, joined_at)
VALUES (518273645, 'Артём', '2026-03-03');

Читается почти по-человечески: «вставь в таблицу users в колонки user_id, name, joined_at значения 518273645, 'Артём' и '2026-03-03'». Текст в SQL берётся в одинарные кавычки, числа — без кавычек.

Но в реальном боте мы не знаем заранее ни id, ни имя — они приходят от пользователя. И тут важнейшее правило безопасности: никогда не склеивай SQL-запрос из пользовательских данных вручную. Вместо подстановки значений в строку используют ? — знак вопроса как «дырку», куда база сама аккуратно подставит данные:

conn.execute(
    "INSERT INTO users (user_id, name, joined_at) VALUES (?, ?, ?)",
    (user_id, name, joined_at),
)
conn.commit()

Результат: в таблицу users добавится новая строка с данными пользователя. Значения из кортежа (user_id, name, joined_at) встанут на места трёх знаков ? по порядку.

Почему именно ?, а не склейка строк? Если подставлять текст от пользователя прямо в запрос, кто-нибудь хитрый может вписать вместо имени кусок SQL-команды и сломать или вычистить твою базу. Это называется SQL-инъекция. Знаки ? закрывают эту дыру: база воспринимает подставленное строго как данные, а не как команду. Привыкай делать так всегда — это не «когда-нибудь потом», а с самого первого запроса.

Что, если такой пользователь уже есть

Помнишь, user_id у нас PRIMARY KEY — уникальный. Если попробовать вставить пользователя, который уже есть в таблице, база возмутится и кинет ошибку. Нам это даже на руку: при /start мы хотим записывать человека только в первый раз. Удобный приём — INSERT OR IGNORE: «вставь, а если такой уже есть — молча пропусти».

conn.execute(
    "INSERT OR IGNORE INTO users (user_id, name, joined_at) VALUES (?, ?, ?)",
    (user_id, name, joined_at),
)
conn.commit()

Результат: если пользователя в таблице ещё нет — он добавится; если уже есть — запрос ничего не сделает и ошибку не выбросит. Никаких дублей.

SELECT: читаем данные из базы

Записывать научились — теперь надо уметь доставать. За чтение отвечает SELECT: «выбери». Например, «выбери всё из таблицы users»:

SELECT user_id, name, joined_at FROM users;

Чаще нам нужна не вся таблица, а одна конкретная строка — например, проверить, есть ли уже пользователь с таким id. Для этого добавляют условие WHERE («где»):

SELECT name FROM users WHERE user_id = 518273645;

В Python это выглядит так. Обрати внимание: тут тоже ? вместо подстановки, и результат надо «забрать» отдельным вызовом:

cursor = conn.execute(
    "SELECT name FROM users WHERE user_id = ?",
    (user_id,),
)
row = cursor.fetchone()

if row is None:
    print("Такого пользователя ещё нет")
else:
    print("Нашёлся:", row[0])

Результат: код спросит у базы строку с нужным user_id. Если такого пользователя нет, fetchone() вернёт None; если есть — вернёт строку, из которой row[0] достанет имя.

Два новых слова:

  • cursor.fetchone() — «дай одну строку результата». Вернёт либо строку с данными, либо None, если ничего не нашлось. Идеально для проверки «есть ли такой».
  • cursor.fetchall() — «дай все строки сразу», списком. Это пригодится, когда надо пройтись по всем пользователям, например для рассылки.

Заметь маленькую, но коварную деталь: даже когда подставляем всего одно значение, мы пишем (user_id,) — с запятой в конце. Без запятой Python поймёт это не как кортеж из одного элемента, а просто как число в скобках, и запрос сломается. Эта запятая — частый источник ошибок у новичков.

Собираем всё в хэндлере /start

А теперь — обещанный бот из начала урока. Свяжем базу с aiogram: при /start бот проверит, знаком ли уже пользователь, и либо запишет нового, либо узнает старого. Вот целиком кусок bot.py, который добавляется к нашему «Цыплёнку-помощнику»:

import sqlite3
from datetime import date
from aiogram.filters import CommandStart
from aiogram.types import Message

# Соединение с базой создаём один раз при старте бота
conn = sqlite3.connect("chick.db")
conn.execute(
    """
    CREATE TABLE IF NOT EXISTS users (
        user_id    INTEGER PRIMARY KEY,
        name       TEXT,
        joined_at  TEXT
    )
    """
)
conn.commit()

@dp.message(CommandStart())
async def start(message: Message):
    user_id = message.from_user.id
    name = message.from_user.first_name

    # Уже знаем этого человека?
    cursor = conn.execute(
        "SELECT user_id FROM users WHERE user_id = ?",
        (user_id,),
    )
    known = cursor.fetchone()

    if known is None:
        conn.execute(
            "INSERT INTO users (user_id, name, joined_at) VALUES (?, ?, ?)",
            (user_id, name, date.today().isoformat()),
        )
        conn.commit()
        await message.answer(
            f"🐤 Привет, {name}! Рад знакомству — занёс тебя в свою записную книжку."
        )
    else:
        await message.answer(
            f"🐤 С возвращением, {name}! Мы уже знакомы 😉"
        )

Результат: при первом /start бот не найдёт пользователя в базе, добавит его и поздоровается как с новеньким; при повторном /start — найдёт и ответит «С возвращением». Запись лежит в файле chick.db, поэтому даже после перезапуска бота он всё равно узнает знакомого.

Разберём логику по шагам:

  1. message.from_user.id и message.from_user.first_name — Telegram присылает в каждом сообщении, кто его отправил. Берём оттуда id и имя.
  2. SELECT ... WHERE user_id = ? + fetchone() — спрашиваем базу, есть ли уже такой пользователь.
  3. Если known is None — человек новый: делаем INSERT, commit() и приветствуем как новенького. date.today().isoformat() даёт сегодняшнюю дату строкой вроде "2026-03-03".
  4. Иначе — пользователь уже в базе, просто здороваемся иначе.

Кстати, формирование даты — это чистый Python без всякого Telegram, его можно проверить прямо в браузере:

from datetime import date

today = date.today().isoformat()
print("Дата для базы:", today)
print("Тип значения:", type(today).__name__)

Вывод:

Дата для базы: 2026-06-19
Тип значения: str

Частые ошибки и подводные камни

1. Забыл conn.commit()

Самая частая и самая обидная ошибка. Ты делаешь INSERT, бот отвечает «записал», ты радуешься — а после перезапуска база пустая. В чём дело? Без commit() изменения не сохраняются на диск, они живут только в памяти текущего соединения. Правило простое: после любого INSERT (или другого изменения данных) — обязательно conn.commit().

2. Склейка SQL вместо знаков ?

Соблазнительно написать запрос через f-строку: f"... WHERE name = '{name}'". Не делай так никогда. Во-первых, это открывает дорогу SQL-инъекциям. Во-вторых, любое имя с апострофом (например, O'Brien) сломает запрос. Всегда подставляй данные через ? и кортеж — это и безопасно, и надёжно.

3. Потерянная запятая в кортеже на одно значение

Передаёшь в запрос одно значение и пишешь (user_id) без запятой. Python видит тут не кортеж, а просто число в скобках, и sqlite3 ругается, что параметров не столько, сколько ?. Запомни: для одного параметра — обязательно (user_id,) с хвостовой запятой.

4. Создание таблицы без IF NOT EXISTS

Если написать просто CREATE TABLE users (...), то при первом запуске всё хорошо, а при втором бот падает: «таблица users уже существует». Поскольку CREATE TABLE у нас выполняется при каждом старте бота, без IF NOT EXISTS бот сможет запуститься только один раз. Всегда пиши CREATE TABLE IF NOT EXISTS.

5. Ждёшь, что fetchone() вернёт само имя

Новички часто пишут name = cursor.fetchone() и удивляются, почему в имени что-то вроде ('Артём',). Дело в том, что fetchone() возвращает строку таблицы — кортеж из колонок, даже если колонка одна. Чтобы достать само значение, бери первый элемент: row[0]. А перед этим проверь, что row вообще не None.

Мини-практика: команда /count

Теперь твой ход. Добавь «Цыплёнку-помощнику» команду /count, которая покажет, сколько всего пользователей уже в базе.

  • По команде /count бот отвечает: «🐤 В моей записной книжке уже N человек!».
  • Число N бот должен брать из базы, а не из переменной.

Подсказки: в SQL есть специальный запрос для подсчёта строк — SELECT COUNT(*) FROM users. Он вернёт одну строку с одним числом. Забери результат через cursor.fetchone(), и не забудь, что само число лежит в row[0] (помни про обёртку-кортеж из прошлого пункта ошибок!). Хэндлер вешай на @dp.message(Command("count")) — фильтр Command мы уже разбирали в модуле про команды.

Когда заработает — попробуй усложнить: команда /last, которая показывает имя последнего записавшегося пользователя. Подсказка: SELECT name FROM users ORDER BY joined_at DESC LIMIT 1 — «отсортируй по дате от новых к старым и дай только одну строку».

Итоги

Сегодня наш «Цыплёнок-помощник» обзавёлся настоящей памятью. Теперь он не забывает пользователей при перезапуске — все они лежат в файле chick.db. Что у тебя теперь в арсенале:

  • SQLite — база данных в одном файле, без сервера, встроена в Python через sqlite3;
  • CREATE TABLE IF NOT EXISTS — создать таблицу один раз и не бояться повторных запусков;
  • INSERTINSERT OR IGNORE) — добавить строку, обязательно через ? и с commit();
  • SELECT ... WHERE + fetchone()/fetchall() — прочитать данные обратно;
  • золотое правило безопасности: данные подставляем только через ?, никогда не склеиваем SQL руками.

Главная мысль урока: база данных превращает бота из «золотой рыбки с памятью на три секунды» в собеседника, который тебя помнит. А весь секрет — один скромный файл рядом с кодом.

В следующем уроке мы пойдём дальше: научимся не просто записывать пользователей, а обновлять и удалять их данные (команды UPDATE и DELETE) — например, менять имя, считать очки в игре или убирать тех, кто заблокировал бота. База станет по-настоящему живой. До встречи!

Проверьте себя
1. Почему SQLite удобна именно для небольшого Telegram-бота?
AОна хранит данные в облаке и не занимает место на диске
BВся база — это один файл, отдельный сервер запускать не нужно, а модуль sqlite3 уже встроен в Python
CОна работает быстрее любой другой базы при миллионах пользователей
DОна автоматически шифрует токен бота
2. Зачем при создании таблицы писать CREATE TABLE IF NOT EXISTS, а не просто CREATE TABLE?
AIF NOT EXISTS ускоряет создание таблицы
BБез этого таблица будет доступна только для чтения
CЧтобы при повторном запуске бота команда не падала с ошибкой «таблица уже существует»
DЭто требование aiogram для всех хэндлеров
3. Как правильно подставить имя пользователя в SQL-запрос из Python?
AЧерез f-строку: f"INSERT ... VALUES ('{name}')"
BЧерез знак ? в запросе и передачу значений отдельным кортежем
CСклеив строку оператором +
DИмя нельзя записывать в базу, только числа
4. Что произойдёт, если после INSERT забыть вызвать conn.commit()?
AБот сразу упадёт с ошибкой
BИзменения не сохранятся на диск и пропадут после перезапуска бота
CЗапись продублируется дважды
DТаблица автоматически удалится
5. Что вернёт cursor.fetchone(), если в таблице нет строки, подходящей под условие WHERE?
AПустую строку ""
BПустой список []
CNone
DНоль (0)
6. Почему при передаче одного значения в запрос пишут (user_id,) с запятой?
AЗапятая ускоряет выполнение запроса
BБез запятой Python воспримет это как обычное число в скобках, а не как кортеж из одного элемента, и запрос сломается
CТак требует команда SELECT
DЗапятая нужна только для строк, а не для чисел