У тех, кто не кодит, бытует мнение, что у разработчиков на любую задачу есть какое-то одно решение — бест-практика. Достаточно почитать SO или, вот теперь, пообщаться с нейронкой и — вуаля — задача сделана. На практике, даже у такой задачи, как вставить данные в БД, есть варианты решения, каждый со своими плюсами и минусами. Делюсь небольшим примером.

Задача

Пилю мок-утилиту для тестирования сервиса на работе. В одной из задач по утилите нужно было воркеру построчно валидировать XLSX-файл и результат записывать в БД. Далее результат подхватывал следующий воркер и отправлял DTO во внешнее API.

Я решал, как делать вставку данных для первого воркера. Код ниже синхронный, так как воркер написан на синхронной либе rq. Синтаксис запросов в БД на SQLAlchemy.

Реализация один: построчное сохранение в БД

Это было моим mvp-решением в виду простоты. Алгоритм:

  1. Читаем листы файла

  2. Валидируем строки файла с помощью Pydantic

  3. Собираем список из строк на вставку

  4. Построчно коммитим в БД.

data_list: list[dict[str, Any]] = ...

try:
    with SessionLocal() as db, db.begin(): # Контекстный менеджер сессии и транзакции
      for row in data_list: # Итерируем список с данными и каждую строку вставляем
        stmt = ...
        db.execute(stmt)
        
except SQLAlchemyError as e:
    ...
  • Плюсы: простота. Легкость в обработке построчных исключений. Устойчивость к битым данным: аффектятся только битые строки, их сразу можно пометить, как проблемные. Остальное запишем.

  • Минусы: низкое быстродействие при больших объемах данных. Файлы пока у нас 1000 - 20 000 строк. Загружать файлы при тестировании будет вся команда QA. Дальше нагрузка может быть больше, если утилиту будем использовать в проде как источник данных.

Батчинг

Метод я решил отрефачить на вставку батчами — пачками данных, по 500–1000 строк за транзакцию. Таких транзакций может быть пара десятков Postgres и стенд спокойно это переварят. Наша команда владеет данными, может оперативно отлаживать битые строки.

Наполняем батч через валидацию Pydantic-схемы:

batch: list[dict[str, Any]] = []
for sheet_name, rows in data.items():  # вытаскиваем название листа и строки файла
    for row in rows:  # итерируемся по каждой строке
        insert_obj = FileRowsInsertSchema(
            job_id=job_id,
            sheet_name=sheet_name,
            row_num=row.row_num,
            row_data=row.row_data,
            status=row.row_status,
            errors=row.errors,
        )
        batch.append(insert_obj.model_dump())  # собираем список отвалидированных словарей для вставки

Из данных XLSX-файла берём листы и строки каждого листа, кастим строки в Pydantic-модель и дампаем их в словари. Получаем список словарей, готовый к вставке в таблицу БД. В какой момент формировать батч на вставку? Основных вариантов два: во время транзакции и до.

Реализация два: собирать батчи во время транзакции

  1. Открываем транзакцию

  2. Наполняем список отвалидированными данными

  3. По мере наполнения чанка, вставляем его в БД

  4. Очищаем батч и повторяем для следующего чанка

  5. После завершения цикла не забываем вставить остаток, размер которого меньше чанка

try:
    with SessionLocal() as db, db.begin():
        # помечаем джоб как VALIDATING
        stmt = select(FileJobsModel).where(FileJobsModel.job_id == job_id)
        job = db.execute(stmt).scalar_one_or_none()
        job.status = JobStatusEnum.VALIDATING.value

        # наполняем батч внутри транзакции
        ...

                # как только батч наполнен, вставляем в БД и чистим батч
                if len(batch) >= config.db.batch_size:
                    db.execute(insert(FileRowsModel).values(batch))
                    batch.clear()
        # когда чанки закончились, заливаем в БД остаток
        if batch:
            db.execute(insert(FileRowsModel).values(batch))

except SQLAlchemyError as e:
    ...
  • Плюсы. У способа низкий риск out-of-memory: мы управляем размером списка, регулярно его очищаем. Подходит для нод с ограниченными ресурсами, например для тестовых стендов.

  • Минусы — хрупкость. В основном, из-за того, что иксель ненадежный источник данных. В упавшем батче нужно искать проблемные строки и решать, что с ними делать. Плюс, потенциально долгая транзакция: мы делаем валидацию, сборку батча и проверки внутри одной транзакции, держим локи и соединение с БД дольше. А также смешение зон ответственности. Код транзакции отвечает за валидацию данных.

Реализация три: собрать полный список на вставку до транзакции

К этому варианту пришел пообщавшись с нейронкой. Идея в том, чтобы оставить у функций только профильные зоны ответственности и не грузить логику вставки данных вычислительной логикой

  1. Собираем полный список данных заранее

  2. Открываем транзакцию

  3. Внутри неё нарезаем чанки и вставляем в БД

try:
    # собираем batch до транзакции (код выше)
    # ...

    with SessionLocal() as db, db.begin():  # открываем сессию и транзакцию
        stmt = select(FileJobsModel).where(FileJobsModel.job_id == job_id)
        job = db.execute(stmt).scalar_one_or_none()
        job.status = JobStatusEnum.VALIDATING.value

        # вставка чанков
        chunk_size = config.db.chunk_size  # размер чанка из конфига
        for i in range(0, len(batch), chunk_size):
            chunk = batch[i : i + chunk_size]
            db.execute(insert(FileRowsModel).values(chunk))

except SQLAlchemyError as e:
    pass
  • Плюсы — разделение зон ответственности: сбор и валидация данных выполняются до транзакции, код взаимодействия с БД только нарезает и инсертит. Транзакции короче, локи и конкуренция за коннекты ниже.

  • Минусы — аналогично варианту выше из-за икселя, как источника данных. А также память O(n). Если файл содержит десятки тысяч строк и много столбцов/листов, на слабой ноде может случиться OOM. Нужны надёжные политики ретраев, хелс-чеки и обработка рестартов воркера.

Я пока остановился на третьем варианте для нашей задачи. Поставим эмпирически размер чанка, чтобы сбалансировать скорость и хрупкость, настроим флоу для завалившихся инсертов и в путь. Конфликты уникальных ключей при вставке либо можно скипнуть с помощью `INSERT ... ON CONFLICT DO NOTHING/DO UPDATE`, либо использовать метод `Divide & Conquer` и уменьшить размер батча при повторной попытке инсерта. Это не такой простой механизм, как построчная вставка, но будем посмотреть.

```

def _insert_batch_or_split(
    db: Session,
    model: type,
    rows: list[dict[str, Any]],
    *,
    min_batch_size: int = 1,
) -> None:
    if not rows:
        return
    try:
        with db.begin():  # отдельная транзакция для этого батча
            db.execute(insert(model), rows)
            logger.info("Успешная вставка батча", size=len(rows))
    except IntegrityError as exc:
        if len(rows) <= min_batch_size:
            logger.error("Пропускаем невалидную строку в батче", row=rows[0], error=str(exc))
            return
        mid = len(rows) // 2
        # Сплитуем и вызываем метод для суб-батчей
        _insert_batch_or_split(db, model, rows[:mid], min_batch_size=min_batch_size)
        _insert_batch_or_split(db, model, rows[mid:], min_batch_size=min_batch_size)

Итого

На вскидку можно предложить еще несколько вариантов решения теми или иными способами. У любых вариантов есть свои юзкейсы и ограничения. И это делает тему компромиссов и мышления полутонами в проектировании такой интересной. Задачи должны поощрять осознанный выбор следующего шага. А из осознанных шагов должны складываться надежные и полезные системы.

Комментарии (11)


  1. Dhwtj
    25.01.2026 20:28

    Без нефункциональных требований по ОЗУ, скорости, размеру файла ничего нельзя сказать однозначно.

    Даже первый пример с инсертом на каждую строчку может оказаться оптимальным в некоторых обстоятельствах.


    1. myshkin_does_it Автор
      25.01.2026 20:28

      полностью согласен! я его обошел и как-то сразу в сторону батчевой вставки ушел, но это как раз подтверждает идею: для каждой реализации есть свои кейсы


      1. tuxi
        25.01.2026 20:28

        В таком примере, батч не очень хорошая идея потому, что если в пакете будет один или два инсерта, который БД не примет (например там uni ключ сработает), вам придется откатываться и что-то делать - искать эту строку инсерта и как то решать, что с ней делать. А такие ситуации обязательно будут, такие внешние источники данных, как ексель, обязательно принесут вместе с собой какой-либо бардак и нарушение контракта на формат данных.
        Ну и порядок в 1000 строк - нет смысла батч использовать, в моей практике дешевле построчно вставлять.


        1. myshkin_does_it Автор
          25.01.2026 20:28

          ну кстати, вполне себе аргумент, в статью добавлю


  1. Akina
    25.01.2026 20:28

    К сожалению, Постгресс, в отличие от некоторых других СУБД, безусловно считает транзакцию проваленной и требующей отката, если в ходе выполнения запросов транзакции возникла ошибка, и не допускает мысли, что ошибка при выполнении одного из запросов может быть штатным течением транзакции. Но это можно пофиксить вложенными транзакциями. В SQL-коде это выглядит пусть и монстрообразно, но оно работоспособно.

    create table test (id int, check (id < 10));
    
    begin;
        begin;
            insert into test values (1);  -- запись вставляется
            commit;                       
        begin;
            insert into test values (11); -- ошибка ограничения
            commit;                       -- субтранзакция откатывается
        begin;
            insert into test values (5);  -- запись вставляется
            commit;
        commit;                           -- фиксируется вставка 2 записей
    select * from test;

    Внешняя транзакция коммиттится, сохраняя в таблицу записи со значениями 1 и 5, несмотря на то, что внутри при добавлении записи со значением 11 возникла ошибка - она маскируется откатом вложенной транзакции.

    Такой подход позволяет, с одной стороны, выполнять вставку пусть и по одной записи, но внутри транзакции, что может оказаться быстрее, чем простая построчная вставка, и в то же время решить вопрос с невалидными записями.

    Будет ли это быстрее или медленнее, чем простая построчная запись - не скажу, а протестировать не на чем. И уж тем более не скажу, будет ли это быстрее из программного кода. Но подход в принципе имеет право на существование.

    PS. Например, в MySQL такой подход сильно ускоряет построчную вставку - но там не требуются вложенные транзакции.


    1. myshkin_does_it Автор
      25.01.2026 20:28

      Думаю он и в питоне похоже может выглядеть (пример ниже) но, вероятно будет медленнее. Ошибки строк обрабатываются до попытки вставки в БД через схемы, поэтому сырые данные в БД не попадают.

      # Логика валидации
      def foo() -> bar:
        ...
      
      with SessionLocal() as db, db.begin():
        try:
          with db.begin_nested(): # Создаем savepoint для отката
            stmt = insert(model).values(bar)
            db.execute(bar)
        except SQLALchemyError as e:
          ...


      1. Akina
        25.01.2026 20:28

        ??? Вот сейчас не понял, причём совсем. И, кмк, потому, что вы меня поняли неправильно.

        Валидация в питоновском коде у вас есть по-любому, и по-любому же она построчная. Так что в этом вопросе разницы я не вижу вообще - хоть построчно вставляем, хоть пакетами. А вот уже потом отвалидированные на уровне python-кода строки вы вставляете в таблицу, т.е. пересылаете наконец на SQL-сервер. И именно на этой стадии у вас при пакетной вставке появляется то, что вы называете "хрупкостью". И об альтернативной реализации именно этой стадии я и говорю - с одной стороны, вставка выполняется пусть и построчно, но внутри транзакции, следовательно, фиксация вставленных строк будет пакетная, с другой стороны, не прошедшая для отдельных записей валидация на уровне SQL-схемы (констрейнты и внешние ключи) не приводит к отказу от вставки всего пакета, отказ выполняется только для невалидных с точки зрения схемы БД отдельных записей.


        1. myshkin_does_it Автор
          25.01.2026 20:28

          подход понятен, имеет место быть


  1. ALexKud
    25.01.2026 20:28

    А не проще ли весь файл залить в таблицу а потом с ней работать средствами sql? Я бы делал так. Кстати, есть вроде расширение для sqlite, работающее с csv файлами как с таблицей. Можно тоже попробовать.


    1. myshkin_does_it Автор
      25.01.2026 20:28

      а в чем преимущества? у нас и так есть хранилка, где лежит входящий файл. просто лишний шаг добавляем


      1. Akina
        25.01.2026 20:28

        а в чем преимущества?

        На самом деле преимущества-то, наверное, имеются.

        Во-первых, сервер БД работает с данными более эффективно, чем фреймворк - как-никак, он для этого создан. И к тому же он как раз заточен на выполнение пакетных операций. Итерации для него не очень естественны, хотя и вполне возможны.

        Во-вторых, вместо двух валидаций (в клиентском коде и затем на стороне СУБД) вы получаете одну. Оформляете её в хранимую процедуру и выполняете, когда потребуется. Не надо ни воркеров, ни листенеров, ни чего-то ещё - послал запрос на выполнение, передав имя исходного файла, и жди готового результата, просто и плоско, как блин. К тому же если потребуется вносить изменения в код - у вас всего одна точка его хранения и выполнения, не надо прыгать с обновлением клиентов на вашем компьютерном зоопарке.

        В третьих, вы можете после валидации и вставки, в том же запросе, удалить вставленные данные из такой промежуточной таблицы, и сразу получить набор записей, которые не прошли валидацию и требуют ручной обработки (ну или могут быть использованы иным путём). Или, если валидация показала критически кривые данные, откатить вообще всё нафиг лёгким движением руки, а не трясти бэкапы.

        PostgreSQL умеет импортировать CSV-файлы в таблицу простейшим запросом COPY. Причём для исключения проблем импорта (особенно если исходный файл потенциально ну очень кривой и может организовать проблемы вроде несовпадения типов или там кодировок) можно просто тянуть всю строку в один блоб и потом на стороне же SQL парсить. Кстати, именно по причине возможности таких недостатков исходных данных и не советую использовать врапперы для подключения внешнего CSV как таблицы.

        А для снижения вероятности блокировок рабочих таблиц - импорт в блоб, парсинг, валидация и вставка в структурную копию рабочей таблицы, вставка обработанных и валидированных данных в рабочую таблицу. Минимум интерференций с другими процессами.