Файловый ввод-вывод в Go: четыре подхода и когда какой выбрать
В стандартной библиотеке Go минимум четыре способа прочитать и записать файл — от однострочного os.ReadFile до потокового io.Reader. Разбираем, чем они отличаются по памяти и удобству, и даём критерии выбора для реальных задач.

Почему один и тот же файл читают по-разному
Новый проект на Go, задача простая: прочитать конфиг, обработать CSV, записать лог. Открываете документацию — и видите os.ReadFile, os.Open + bufio.Scanner, io.ReadAll, ручной Read в буфер фиксированного размера. Все работают, все в стандартной библиотеке, ни один не помечен как устаревший. Разница — в потреблении памяти, контроле над процессом и читаемости кода. Ниже — четыре подхода с конкретными примерами на Go 1.22, их стоимость и ситуации, где каждый уместен.
Подход 1. Прочитать всё разом — os.ReadFile / os.WriteFile
Самый короткий путь: одна функция, один вызов, результат — []byte целиком в памяти.
// Go 1.22
package main
import (
"fmt"
"os"
)
func main() {
// Чтение файла целиком
data, err := os.ReadFile("config.json")
if err != nil {
fmt.Fprintf(os.Stderr, "не удалось прочитать файл: %v\n", err)
os.Exit(1)
}
fmt.Printf("прочитано %d байт\n", len(data))
// Запись файла целиком
err = os.WriteFile("output.txt", data, 0644)
if err != nil {
fmt.Fprintf(os.Stderr, "не удалось записать файл: %v\n", err)
os.Exit(1)
}
}
Что получаете: минимум кода, явное закрытие дескриптора внутри функции — вам не нужно думать о defer f.Close().
Чем платите: весь файл ложится в память. Для конфига на 2 КБ это не проблема. Для дампа БД на 800 МБ — Out of Memory на контейнере с лимитом в гигабайт.
Подход 2. Построчное чтение — bufio.Scanner
Классика для текстовых файлов: логи, CSV без кавычек, построчные данные.
package main
import (
"bufio"
"fmt"
"os"
)
func main() {
file, err := os.Open("access.log")
if err != nil {
fmt.Fprintf(os.Stderr, "ошибка открытия: %v\n", err)
os.Exit(1)
}
defer file.Close()
scanner := bufio.NewScanner(file)
// Увеличиваем буфер, если строки длиннее 64 КБ
scanner.Buffer(make([]byte, 0, 256*1024), 256*1024)
lineCount := 0
for scanner.Scan() {
_ = scanner.Text() // обработка строки
lineCount++
}
if err := scanner.Err(); err != nil {
fmt.Fprintf(os.Stderr, "ошибка чтения: %v\n", err)
os.Exit(1)
}
fmt.Printf("строк: %d\n", lineCount)
}
Что получаете: предсказуемый расход памяти — в буфере лежит одна строка, а не весь файл.
Чем платите: по умолчанию bufio.Scanner ограничивает строку 64 КБ. Если строка длиннее и вы не вызвали scanner.Buffer, сканер вернёт ошибку bufio.Scanner: token too long — молча, без паники, и вы можете не заметить потерю данных, если не проверите scanner.Err().
Частая ловушка: потерянная ошибка сканера
Вызов scanner.Scan() возвращает false и при достижении конца файла, и при ошибке. Единственный способ различить — вызвать scanner.Err() после цикла. Пропустили проверку — не узнали, что файл прочитан не до конца.
Подход 3. Потоковое чтение через io.Reader и фиксированный буфер
Когда данные бинарные или вы хотите контролировать размер порции.
package main
import (
"fmt"
"io"
"os"
)
func main() {
src, err := os.Open("dump.bin")
if err != nil {
fmt.Fprintf(os.Stderr, "ошибка: %v\n", err)
os.Exit(1)
}
defer src.Close()
dst, err := os.Create("copy.bin")
if err != nil {
fmt.Fprintf(os.Stderr, "ошибка: %v\n", err)
os.Exit(1)
}
defer dst.Close()
buf := make([]byte, 32*1024) // 32 КБ — порция копирования
for {
n, readErr := src.Read(buf)
if n > 0 {
if _, writeErr := dst.Write(buf[:n]); writeErr != nil {
fmt.Fprintf(os.Stderr, "ошибка записи: %v\n", writeErr)
os.Exit(1)
}
}
if readErr == io.EOF {
break
}
if readErr != nil {
fmt.Fprintf(os.Stderr, "ошибка чтения: %v\n", readErr)
os.Exit(1)
}
}
}
Обратите внимание на порядок: сначала проверяем n > 0, потом — ошибку. Спецификация io.Reader разрешает вернуть данные и io.EOF одновременно. Если проверить ошибку первой, вы потеряете последнюю порцию.
io.Copy — та же идея, меньше кода
Для простого копирования потоков io.Copy(dst, src) делает ровно то же самое, аллоцирует буфер в 32 КБ и копирует до io.EOF. Используйте ручной цикл, когда нужна обработка каждой порции — хеширование, подсчёт, трансформация на лету.
Подход 4. Буферизованная запись — bufio.Writer
Частая задача: писать много мелких фрагментов — строки лога, строки CSV, результаты обработки. Каждый вызов os.File.Write — это системный вызов. Тысяча строк по 80 байт — тысяча сисколов.
package main
import (
"bufio"
"fmt"
"os"
)
func main() {
file, err := os.Create("report.csv")
if err != nil {
fmt.Fprintf(os.Stderr, "ошибка: %v\n", err)
os.Exit(1)
}
defer file.Close()
writer := bufio.NewWriterSize(file, 64*1024) // буфер 64 КБ
for i := 0; i < 100_000; i++ {
fmt.Fprintf(writer, "%d,значение_%d\n", i, i)
}
// Обязательно: сбросить остаток буфера на диск
if err := writer.Flush(); err != nil {
fmt.Fprintf(os.Stderr, "ошибка flush: %v\n", err)
os.Exit(1)
}
}
Главная ошибка: забыть Flush(). Без него последние данные останутся в буфере и не попадут в файл. defer file.Close() закроет дескриптор, но буферизатор об этом не знает — он обёртка, а не владелец файла.
Сравнение подходов
| Подход | Потребление памяти | Контроль | Когда подходит | Типичная ошибка |
|---|---|---|---|---|
| os.ReadFile / os.WriteFile | Весь файл в RAM | Минимальный | Конфиги, шаблоны, мелкие файлы до нескольких МБ | Подсунули большой файл — OOM |
| bufio.Scanner | Одна строка + буфер | Построчный | Логи, CSV, текстовые потоки | Не проверили Err(), не увеличили буфер |
| io.Reader + буфер | Размер буфера (вы выбираете) | Побайтовый / по порции | Бинарные файлы, копирование, хеширование | Проверили ошибку до обработки n байт |
| bufio.Writer | Размер буфера | Пишете когда хотите, сброс — ваша задача | Много мелких записей | Забыли Flush() |
Выбор подхода — это всегда компромисс между удобством и контролем.
os.ReadFileэкономит строки кода, ручнойRead— мегабайты памяти. Вопрос не «какой лучше», а «что дороже в вашей задаче — память или время разработчика».
Критерии выбора: конкретные правила
Не «зависит от задачи», а конкретнее:
- Файл помещается в голове целиком (конфиг, JSON-схема, маленький шаблон) —
os.ReadFile. Верхняя граница — единицы мегабайт; если в проде файл может вырасти непредсказуемо, это уже не ваш случай. - Текстовый файл, обработка построчная (логи, CSV без вложенных кавычек, построчный экспорт) —
bufio.Scanner. Не забудьте задать буфер, если строки могут быть длинными. - Бинарные данные или нужна обработка порциями (копирование с хешированием, стриминг, работа с архивами) — ручной
Readилиio.Copy. - Генерация текста или данных на лету (отчёты, дампы, построчная запись) —
bufio.Writerповерхos.File.
Общие ошибки, которые встречаются во всех подходах
- Не проверять ошибку Close(). Запись на сетевой диск или NFS может вернуть ошибку именно при закрытии: данные из кеша ядра не сбросились. Если результат записи важен, проверяйте:
if err := file.Close(); err != nil { ... }. При этомdefer file.Close()для подстраховки всё равно уместен — но уже без проверки ошибки, просто как гарантия закрытия дескриптора. - Гонка defer'ов.
defer writer.Flush()иdefer file.Close()исполняются в обратном порядке объявления. ЕслиCloseобъявлен позжеFlush, он выполнится первым — иFlushзапишет в уже закрытый файл. Объявляйтеdefer file.Close()раньше, чемdefer writer.Flush(). - Права доступа 0777.
os.WriteFileпринимаетos.FileMode. Указывать0777по привычке — дарить всем пользователям системы право писать в ваш файл. Для обычных файлов достаточно0644, для приватных —0600.
Сноска: io.Reader и io.Writer — интерфейсы стандартной библиотеки Go, определяющие единственный метод (Read или Write). Большинство функций ввода-вывода в Go работают именно с этими интерфейсами, а не с конкретными типами файлов, — поэтому один и тот же код читает из файла, из сети и из буфера в памяти.
Что дальше
Когда файловый ввод-вывод перестаёт быть узким местом, следующий шаг — параллельная обработка. Если вы читаете и разбираете большой файл, попробуйте разделить чтение (один горутин с bufio.Scanner) и обработку (пул горутин через каналы). Но это уже другая тема: конкурентность и каналы.
Для замеров производительности разных подходов используйте встроенный пакет testing и бенчмарки (go test -bench): напишите BenchmarkReadFile и BenchmarkScannerRead для файла вашего типичного размера — цифры на вашем железе скажут больше, чем любая таблица из статьи.
Задачи: математика, физика, химия: Разбор задачи по шагам с проверкой и похожей задачей
Спросить за 10 ₽Источники: Пакет os — стандартная библиотека Go, Пакет bufio — стандартная библиотека Go
Частые вопросы
Когда os.ReadFile опасен для продакшена?
Почему bufio.Scanner молча обрезает длинные строки?
Чем io.Copy лучше ручного цикла Read/Write?
Что будет, если забыть вызвать Flush у bufio.Writer?
А если у меня другая версия Go — код будет работать?
Как выбрать размер буфера для ручного Read?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.