---
title: "COPY FROM: миллионы строк за секунды"
description: "COPY FROM в pgx: CopyFrom против цикла INSERT с замером времени, CopyFromRows и CopyFromFunc, бинарный протокол, staging-таблица для дубликатов. Примеры кода."
course: "PostgreSQL в Go: драйвер pgx с нуля до продакшена"
url: "https://golangmaster.ru/courses/pgx/lessons/19-copy-iz-zapisi/"
published: "2026-09-19"
updated: "2026-09-21"
minutes: 20
---

# COPY FROM: миллионы строк за секунды

*Курс «PostgreSQL в Go: драйвер pgx с нуля до продакшена», урок 19 из 25. ~20 минут. Обновлено 21.09.2026. Источник: https://golangmaster.ru/courses/pgx/lessons/19-copy-iz-zapisi/*

Массовая вставка в Postgres через цикл INSERT упирается в арифметику сетевых рейсов, и здесь CopyFrom из pgx меняет порядок величин: бинарный поток строк по протоколу COPY заливает сотни тысяч строк за секунды. Разберём сигнатуру с pgx.Identifier, источники CopyFromRows и CopyFromFunc, границы COPY и staging-паттерн для дубликатов.

Загрузка логов за сутки, импорт прайса поставщика, перенос строк из другой системы — цикл INSERT здесь сдувается: сотни тысяч команд, каждая со своим рейсом, разбором и планом запроса. В Postgres есть встроенный механизм для такого объёма — команда COPY, а в pgx её обёртка CopyFrom. Инструмент честно измеряется: одна и та же загрузка на сто тысяч строк занимает через цикл Exec десятки секунд, а через CopyFrom — единицы, и разница растёт с объёмом; замер кодом покажу сразу, механику разберём после. Контракт: CopyFrom(ctx, tableName, columns, rowSrc) возвращает число вставленных строк и ошибку; tableName — pgx.Identifier из одной-двух частей, columns — список колонок, rowSrc — источник строк. COPY — не апгрейд batch из восемнадцатого урока, а другой инструмент: он умеет только вставку в таблицу, зато делает это на скорости, недостижимой для SQL-команд. И у скорости есть цена в жёсткости: конфликты уникальности COPY не разрешает — один дубликат откатывает всю загрузку, и решение через staging-таблицу станет финальным аккордом урока. Порядок такой: сигнатура и идентификатор таблицы, бенчмарк, механика выигрыша, источники строк, границы COPY и гигиена больших загрузок.

### CopyFrom: сигнатура и Identifier

Полная сигнатура: CopyFrom(ctx context.Context, tableName pgx.Identifier, columnNames []string, rowSrc pgx.CopyFromSource) (int64, error). Identifier — тип на основе среза строк: одна часть — таблица, две — схема и таблица: pgx.Identifier{"public", "events"}. Метод Sanitize оборачивает каждую часть в кавычки и экранирует вложенные кавычки — имя с дефисом или заглавной буквой приезжает на сервер корректно, склейка SQL из строк не нужна. Список колонок задаётся явно: COPY вставляет только их, остальные получают дефолты, поэтому порядок значений в источнике обязан совпадать с порядком колонок — рассинхрон ловится ошибкой вида expected N values, got M. Возвращаемое число — RowsAffected команды COPY, количество реально вставленных строк. Вызов живёт и на пуле, и на транзакции: pool.CopyFrom берёт коннект на время копирования и возвращает его после, tx.CopyFrom делает загрузку частью транзакции из тринадцатого урока — атомарной с остальными изменениями. Привилегии жёстче, чем кажется: сервер требует INSERT на целевую таблицу, и это проверяется на первом прогоне, а не на ревью. NULL передаётся nil в слоте []any — на стороне сервера это честный NULL, типизированные нулевые обёртки из шестого урока здесь не нужны. Контекст действует как везде в pgx: дедлайн из пятнадцатого урока прерывает копирование, и операция откатывается целиком.

### Бенчмарк на глазах: цикл против COPY

```go
const n = 100000

rows := make([][]any, 0, n)
for i := 0; i < n; i++ {
        rows = append(rows, []any{fmt.Sprintf("event-%d", i), i})
}

start := time.Now()
for _, r := range rows {
        _, err := conn.Exec(ctx,
                "INSERT INTO events (name, seq) VALUES ($1, $2)",
                r[0], r[1])
        if err != nil {
                log.Fatal(err)
        }
}
fmt.Println("цикл INSERT:", time.Since(start))

start = time.Now()
count, err := conn.CopyFrom(ctx,
        pgx.Identifier{"events"},
        []string{"name", "seq"},
        pgx.CopyFromRows(rows))
fmt.Printf("CopyFrom: %s, строк %d, ошибка %v\n",
        time.Since(start), count, err)
```

Один и тот же набор строк едет двумя маршрутами с замером time.Since. Ожидание на локальной базе: цикл — десятки секунд, CopyFrom — единицы; на удалённой базе разница шире из-за рейсов. Цифры зависят от железа, схемы и индексов, поэтому запускайте замер сами и фиксируйте свой порядок величин — он пригодится в каждом споре об архитектуре загрузки.

### Почему COPY обгоняет INSERT в разы

Экономия складывается из трёх слагаемых. Первое — рейсы: один поток данных вместо сотни тысяч запросов-ответов, сеть перестаёт быть затычкой. Второе — разбор: INSERT сервер парсит и планирует каждый раз, даже с кэшем стейтментов из восемнадцатого урока; COPY разбирается один раз и дальше принимает строки без SQL вообще. Третье — формат: pgx шлёт данные в бинарном представлении COPY, значения кодируются по OID типов напрямую в байты, а не в текст протокола. Драйвер собирает строки в буферы примерно по 64 килобайта и отправляет потоком — в исходниках pgx это порог в 65536 байт минус заголовок пакета, деталь, объясняющая, почему память приложения не раздувается на миллионных выборках. Между циклом и COPY есть средняя ступенька: INSERT ... SELECT FROM unnest(массивов) — одна команда на пачку строк, без рейсов, но с разбором и планом; удобно на тысячах строк, когда данные уже лежат в Go-срезах. Далеко COPY не резиновый: сервер всё равно пишет страницы, строит индексы и выполняет триггеры, поэтому рост упирается в диск, а не в драйвер. Серверная сторона тоже выигрывает по сопровождению: один оператор в журнале вместо сотни тысяч — парсер, планировщик и лог медленных запросов перестают захлёбываться INSERT-ами. Для практики достаточно порядка: тысячи строк в секунду через цикл против сотен тысяч через COPY — и замер на своих данных.

### Проверка: возвращаемое значение

**Вопрос:** Что возвращает CopyFrom при успешном завершении?

- ✅ Число вставленных строк в int64 и nil
- Курсор строк для чтения
- CommandTag с текстом COPY N
- Ничего, только error

CopyFrom возвращает (int64, error), где int64 — количество вставленных строк, аналог RowsAffected. Курсор возвращает Query, CommandTag — Exec. У COPY формат ответа сервера свой, поэтому pgx сразу распаковывает его в число строк.

### CopyFromSource: Rows, Slice, Func

Источник строк — интерфейс CopyFromSource из трёх методов: Next(), Values() и Err(). Готовых реализаций три. CopyFromRows(rows [][]any) оборачивает срез строк — всё в памяти, удобно для готовых данных и бенчмарков, но миллиард строк в RAM не поместится. CopyFromSlice(length, next func(i int) ([]any, error)) генерирует значения по индексу — вариант для вычисляемых наборов, где размер известен заранее. CopyFromFunc(next func() ([]any, error)) — потоковая передача: функция возвращает очередную строку, сигнализирует конец парой nil и nil, а ошибка из замыкания доезжает до Err() интерфейса и обрывает копирование. Именно CopyFromFunc делает загрузку из файла честным стримом: читаешь CSV построчно через bufio.Scanner и отдаёшь строки в COPY, не загружая файл в память целиком. Расхождение длины строки и списка колонок ловится на лету ошибкой expected N values — источник отвечает за согласованность с самим собой. Собственная реализация интерфейса тоже дёшево: три метода поверх вашего курсора, если данные тянутся из API, очереди или другого соединения — срез нужен не всегда. Для собственной реализации держите в голове контракт Next: ложь означает конец, поэтому ошибка должна возвращаться и в Err() — иначе драйвер примет сбой за конец данных и загрузка молча обрежется; встроенный CopyFromFunc прокладывает ошибку замыкания в Err автоматически.

### Потоковая загрузка CSV

```go
func importCSV(ctx context.Context, conn *pgx.Conn,
        path string) (int64, error) {

        f, err := os.Open(path)
        if err != nil {
                return 0, err
        }
        defer f.Close()

        sc := bufio.NewScanner(f)
        src := pgx.CopyFromFunc(func() ([]any, error) {
                if !sc.Scan() {
                        return nil, sc.Err()
                }
                parts := strings.Split(sc.Text(), ",")
                return []any{parts[0], parts[1]}, nil
        })

        return conn.CopyFrom(ctx, pgx.Identifier{"events"},
                []string{"name", "seq"}, src)
}
```

CopyFromFunc замыкается на сканере файла: каждая строка CSV превращается в []any на лету, пара nil и nil сигнализирует конец потока, ошибка сканера прерывает копирование через Err источника. Память приложения не зависит от размера файла — данные текут порциями, буфер драйвера держит около 64 килобайт. CopyFrom вернёт число загруженных строк.

### Что вернёт CopyFrom?

```go
rows := [][]any{
    {"a", 1},
    {"b", 2},
    {"c", 3, 4},
}

n, err := conn.CopyFrom(ctx,
    pgx.Identifier{"events"},
    []string{"name", "seq"},
    pgx.CopyFromRows(rows))

fmt.Println(n, err)
```

**Что выведет программа?**

- 3 <nil> — лишнее значение отброшено
- ✅ 2 и ошибка expected 2 values, got 3 values
- 0 и ошибка 23505
- 3 <nil>, колонка seq получит значение 4

Источник обязан отдавать столько значений, сколько колонок запрошено: третья строка нарушает контракт, копирование прерывается ошибкой expected 2 values, got 3 values. Всё, что успело скопироваться до сбоя, откатывается — COPY атомарен.

### Цикл, batch и COPY на одной шкале

| Критерий | Цикл Exec | pgx.Batch | CopyFrom |
| --- | --- | --- | --- |
| Рейсов на строку | один на каждую | один на пачку | один на всё |
| Разбор SQL | на каждую команду | на каждую постановку | нет — поток данных |
| UPDATE и DELETE | да | да | нет — только вставка |
| Конфликты уникальности | ошибка строки | откат пачки | откат всего COPY |
| Память приложения | O(1) | O(N) очереди | O(1) с CopyFromFunc |
| Реалистичный объём | единицы | десятки–тысячи | тысячи–миллионы |

### Границы COPY: только вставка и только в таблицу

COPY умеет одно действие — вставлять строки, и от этого пляшут ограничения. UPDATE и DELETE командой COPY не выражаются: инкременты и синхронизации остаются за upsert из семнадцатого урока и batch из восемнадцатого. Цель — таблица: обычная, секционированная или foreign; обычное представление COPY не принимает — сервер ответит ошибкой, если только на вьюхе не объявлен INSTEAD OF INSERT-триггер. Вставка идёт напрямую и не проходит через те же пути, что INSERT: сервер выполняет триггеры и CHECK-ограничения на каждую строку — тяжёлый AFTER-триггер съест часть выигрыша, — а вот правила, rules, не запускает вовсе, и это документированное отличие от INSERT. Генерируемые колонки COPY не пишет: указать их в списке колонок нельзя — сервер ответит ошибкой; без списка колонок они пропускаются и вычисляются сами. Таблицы с row-level security COPY FROM пока не поддерживает — придётся разбивать на INSERT. Конфликтов уникальности COPY не разрешает: 23505 на любой строке обрывает операцию, всё скопированное откатывается, ретрай упрётся в тот же дубликат. В свежих версиях сервера у COPY появилась опция ON_ERROR для пропуска ошибочных строк, но pgx.CopyFrom строит команду сам и опций не выставляет — staging остаётся переносимым решением. Наконец, свои enum-типы требуют LoadType и регистрации в карте типов до копирования — значения кодируются по OID, и незнакомый тип ловится на первом запуске, а не в проде.

> **Живое место: дубль на строке 999 999.** Девятьсот девяносто девять тысяч строк заехали, последняя оказалась дубликатом — и вся загрузка откатилась: время и диск впустую, оператор смотрит на 23505. Если источник данных не вычищен, то упадёт ровно на первом дубликате, каким бы дальним он ни был. Лекарство — staging: COPY во временную таблицу без уникальных ограничений, затем INSERT ... SELECT ... ON CONFLICT DO NOTHING из неё в целевую — массовая загрузка переживает дубликаты, а целевая таблица защищена. Второй вариант — дедупликация в приложении по тем же ключам до копирования.

### Правила больших загрузок

- Десятки тысяч строк и больше — COPY, а не цикл и не batch
- Порядок значений источника обязан совпадать со списком колонок
- CopyFromRows — для данных в памяти, CopyFromFunc — для стрима из файла или API
- Дубликаты уронят загрузку: staging-таблица плюс INSERT ... ON CONFLICT
- Триггеры COPY выполняет, правила не запускает — поведение отличается от INSERT
- Собственные enum-типы требуют LoadType и регистрации до COPY
- COPY атомарен: сбой откатывает всё, ретрай безопасен
- Дедлайн на контексте: миллионная загрузка без таймаута зависает вместе с базой

### Проверка: staging-паттерн

**Вопрос:** Прайс поставщика приходит с возможными дубликатами. Как загрузить его через COPY?

- COPY сразу в целевую таблицу и ретрай при 23505
- ✅ COPY во временную таблицу без уникальных ограничений, затем INSERT ... SELECT ... ON CONFLICT
- Заранее ловить 23505 на каждой строке через INSERT
- COPY игнорирует уникальные индексы — грузить напрямую

Staging отделяет объём от гарантий: COPY быстро заливает сырые данные, а ON CONFLICT разрешает дубликаты на вставке в целевую таблицу. Ретрай при 23505 бесполезен — дубликат приедет снова, а COPY не разрешает конфликты сам.

### Соберите вызов CopyFrom

Восстановите вызов: идентификатор таблицы, колонки, источник.

```go
n, err := conn.___(ctx,
        pgx.___{"public", "events"},
        []string{"name", "seq"},
        pgx.___(rows))
if err != nil {
        log.Fatal(err)
}
fmt.Println(n)
```

Подсказка: Метод CopyFrom, тип Identifier и обёртка среза — CopyFromRows.

Правильные ответы в пропусках: `CopyFrom`, `Identifier`, `CopyFromRows`

### Соберите потоковую загрузку

Расставьте строки: источник-стрим и вызов CopyFrom.

Правильный порядок строк:

1. `sc := bufio.NewScanner(f)`
2. `src := pgx.CopyFromFunc(func() ([]any, error) {`
3. `  if !sc.Scan() {`
4. `    return nil, sc.Err()`
5. `  }`
6. `  parts := strings.Split(sc.Text(), ",")`
7. `  return []any{parts[0], parts[1]}, nil`
8. `})`
9. `return conn.CopyFrom(ctx, pgx.Identifier{"events"}, cols, src)`

### Карточки: COPY FROM

- **CopyFrom** — Массовая вставка через протокол COPY: (ctx, Identifier, колонки, CopyFromSource) — возвращает число строк. Только вставка.
- **pgx.Identifier** — Срез частей имени: таблица или схема и таблица. Sanitize кавычкой экранирует каждую часть.
- **CopyFromSource** — Интерфейс Next, Values, Err. Values обязан вернуть ровно столько значений, сколько колонок запрошено.
- **CopyFromRows и CopyFromFunc** — Rows держит срез в памяти; Func стримит: nil и nil — конец, ошибка прерывает копирование.
- **Атомарность COPY** — Сбой в любой строке откатывает всю загрузку. Ретрай безопасен, частичного успеха нет без staging.
- **Staging-таблица** — COPY во временную таблицу без ограничений, затем INSERT ... SELECT ... ON CONFLICT в целевую. Переживает дубликаты.

> **Живое место: коннект занят на всё копирование.** CopyFrom держит коннект с первого байта потока до финального CommandTag — досрочно отдавать его нечем, и транзакция вокруг него живёт столько же. Если гоните несколько миллионов строк параллельно из пула на восемь коннектов, то обычные запросы начнут ждать, а health-check — краснеть. Лечится порционированием: загрузка режется на куски по несколько сотен тысяч строк с передышкой между ними, либо под COPY выделяется отдельный пул с собственным MaxConns. Бюджет времени ставится контекстом: дедлайн из пятнадцатого урока обрывает копирование и откатывает его.

### Гигиена массовых загрузок

Скорость COPY сдвигает узкое место, и заботы меняются. Контекст получает дедлайн из пятнадцатого урока: загрузка без бюджета при затыке сети держит коннект и транзакцию неопределённо долго. Прогресс логируется по строкам из CopyFromFunc — на миллионе строк тишина в логе выглядит как зависание: счётчик через каждые сто тысяч строк превращает долгую загрузку из загадки в прогресс-бар. Индексы и уникальные ограничения замедляют каждую вставку: на известных тяжёлых загрузках применяют приём снятия индексов и пересоздания после, но это изменение схемы с рисками — сначала измерьте, окупается ли оно у вас. После первой большой загрузки прогоните ANALYZE, чтобы планировщик увидел новую статистику таблицы, — иначе планы строятся по старым оценкам; на порционных загрузках цикл повторяется, поэтому эти два шага стоит повесить на конец самой процедуры, а не на память дежурному. Итоговый чек-лист операции: staging для дубликатов, дедлайн, прогресс в логе, ANALYZE после — и загрузка становится скучной рутиной, чем и должна быть.

COPY — инструмент, после которого цикл INSERT для объёмов выглядит расточительством. Песочница урока не исполняет pgx: интерактивы и разбор кода она покажет, а бенчмарк гоняйте локально или в Docker с живым Postgres из первого урока. Дальше по курсу: в [двадцатом](/courses/pgx/lessons/20-listen-notify/) — LISTEN/NOTIFY и события из базы; в [семнадцатом](/courses/pgx/lessons/17-unikalnost-23505/) — upsert, на котором держится staging-паттерн; в [восемнадцатом](/courses/pgx/lessons/18-batch-zaprosy/) — batch для разнородных команд, когда объём ещё не тянет на COPY; в [шестнадцатом](/courses/pgx/lessons/16-oshibki-pgx/) — разбор PgError, который вернёт сбой посреди потока. Домашнее задание: замерить свои цифры, поймать дубликат на последней строке и починить загрузку staging-таблицей.

## Коротко: что запомнить

- CopyFrom гонит бинарный поток строк через протокол COPY — на порядки быстрее цикла INSERT.
- Сигнатура: CopyFrom(ctx, pgx.Identifier, []string, CopyFromSource) (int64, error); int64 — число строк.
- CopyFromRows держит данные в памяти, CopyFromFunc стримит: nil и nil — конец, ошибка прерывает копирование.
- Порядок значений источника обязан совпадать со списком колонок — рассинхрон ловится ошибкой.
- COPY умеет только вставку в таблицу: UPDATE и синхронизации — за upsert и batch.
- Триггеры COPY выполняет, правила — нет; генерируемые колонки в список не указываются.
- Один дубликат откатывает всю загрузку: staging-таблица плюс INSERT ... ON CONFLICT спасает поток.
- Дедлайн на контексте, прогресс в логе и ANALYZE после — гигиена массовых загрузок.

## Домашнее задание

Проведите бенчмарк из урока на своей машине: сто тысяч строк через цикл Exec и через CopyFrom, зафиксируйте разницу в комментарии. Вставьте дубликат в последнюю строку набора и убедитесь, что вся загрузка откатилась с 23505. Перепишите загрузку через staging-таблицу и INSERT ... SELECT ... ON CONFLICT — дубликат больше не мешает. Затем замените CopyFromRows на CopyFromFunc со сканером файла и проверьте память процесса на файле, который не влезает в RAM. В конце прогоните ANALYZE и сравните план запроса по загруженной таблице.

## Вопросы и ответы по уроку

### Почему COPY быстрее обычных INSERT?

Три слагаемых: одна поездка потока данных вместо рейса на каждую строку, отсутствие разбора и планирования SQL на строку, бинарная упаковка значений по типам. Драйвер собирает буферы примерно по 64 КБ. Выигрыш на порядки, но он упирается в диск, индексы и триггеры сервера — их цена остаётся, поэтому две таблицы с разной схемой дадут разные цифры на одном и том же наборе строк.

### Можно ли COPY с ON CONFLICT?

Нет: протокол COPY не понимает разрешение конфликтов, любой дубликат завершает загрузку откатом. Решение — staging: COPY во временную таблицу без уникальных ограничений, затем INSERT ... SELECT ... ON CONFLICT DO NOTHING или DO UPDATE в целевую. Так массовый объём переживает дубликаты, а гарантии целостности остаются на целевой таблице.

### CopyFrom или batch — что выбрать?

Разнородные команды и десятки-сотни вставок — batch: разные типы результатов и гибкость. Однотипная вставка от тысяч строк — COPY: нет разбора SQL и рейсов на команду. Граница плавающая и зависит от данных; замер на своей базе решает быстрее любых правил.
