Python: чтение больших файлов без лишнего расхода памяти

Пока файл весит несколько мегабайт, его можно прочитать как угодно, и f.read() отлично работает. Когда лог вырастает до 5 ГБ, а выгрузка из базы до 20 миллионов строк, тот же код съедает всю память или работает часами. Ниже собраны проверенные приемы, которые позволяют обрабатывать файлы любого размера при постоянном расходе памяти: построчное чтение, чтение блоками, генераторы, mmap и потоковая обработка CSV.
Почему read и readlines не подходят для больших файлов
Метод read() загружает весь файл в одну строку, а readlines() создает список из всех строк. В обоих случаях памяти нужно больше, чем весит файл: строка Python с кириллицей хранит каждый символ в 2 байтах, а каждый элемент списка добавляет еще около 50 байт служебных данных. Файл на 1 ГБ из коротких строк легко превращается в 3-5 ГБ занятой памяти.
| Способ | Память | Когда использовать |
|---|---|---|
f.read() | весь файл | маленькие файлы, конфиги |
f.readlines() | весь файл и список | почти никогда, лучше list(f) осознанно |
for line in f | одна строка и буфер | текстовые файлы любого размера |
f.readline() | одна строка | когда нужно читать по одной строке вручную, например заголовок |
f.read(size) в цикле | один блок | бинарные файлы, файлы без переводов строк |
mmap | страницы по требованию | поиск и произвольный доступ к огромному файлу |
Базовые режимы открытия файлов и разница между текстовым и бинарным режимом описаны в статье о чтении и записи файлов. Здесь сосредоточимся на объеме.
Построчное чтение файла
Файловый объект в Python сам является итератором по строкам. Цикл for читает данные буфером (обычно 8 КБ и больше) и выдает по одной строке, поэтому в памяти одновременно находится только текущая строка.
errors = 0
with open("access.log", encoding="utf-8") as f:
for line in f:
if " 500 " in line:
errors += 1
print(errors)
У каждой строки, кроме, возможно, последней, есть символ перевода строки на конце. Убирайте его через line.rstrip("\n"). Вызов strip() без аргумента уберет и значимые пробелы в начале строки, что для данных с фиксированной шириной полей недопустимо.
Номера строк и ранний выход
with open("data.txt", encoding="utf-8") as f:
for line_no, line in enumerate(f, start=1):
if line.startswith("#"):
continue
if "STOP" in line:
print(f"маркер найден в строке {line_no}")
break
Ранний break важен: если ответ найден в начале файла, остальные гигабайты не читаются вовсе.
Цикл с readline
Метод readline() полезен, когда нужно прочитать первые строки отдельно (заголовок, версию формата), а потом обработать остальное. При достижении конца файла он возвращает пустую строку.
with open("export.txt", encoding="utf-8") as f:
header = f.readline().rstrip("\n").split(";")
while line := f.readline():
values = line.rstrip("\n").split(";")
record = dict(zip(header, values))
Смешивать for line in f и readline() в одном цикле можно в Python 3, но читается такой код хуже. Предпочтительнее взять заголовок через next(f), а дальше идти обычным for.
Чтение файла по частям (кусками)
Если в файле нет переводов строк (бинарные данные, минифицированный JSON, дамп) или строки огромные, читайте блоками фиксированного размера через read(size).
CHUNK = 1024 * 1024 # 1 МБ
with open("video.mp4", "rb") as f:
while chunk := f.read(CHUNK):
process(chunk)
Классический пример применения: контрольная сумма файла любого размера.
import hashlib
def sha256_file(path, chunk_size=1 << 20):
h = hashlib.sha256()
with open(path, "rb") as f:
while chunk := f.read(chunk_size):
h.update(chunk)
return h.hexdigest()
print(sha256_file("ubuntu.iso")[:16]) # -> первые 16 символов хеша
В Python 3.11+ то же самое делает одна функция hashlib.file_digest(f, "sha256"). Размер блока от 64 КБ до 4 МБ дает почти одинаковую скорость. Слишком маленький блок (например, 100 байт) замедляет чтение в десятки раз из-за накладных расходов на каждый вызов.
Проблема границ блоков
Блок фиксированного размера может разрезать строку или многобайтовый символ UTF-8 пополам. В текстовом режиме read(n) читает n символов, а не байтов, поэтому символы не режутся, но строки могут. Если обрабатываете текст блоками, переносите хвост незавершенной строки в следующий блок:
def lines_from_chunks(path, chunk_size=1 << 20):
tail = ""
with open(path, encoding="utf-8") as f:
while chunk := f.read(chunk_size):
chunk = tail + chunk
*lines, tail = chunk.split("\n")
yield from lines
if tail:
yield tail
В бинарном режиме для текста используйте инкрементальный декодер codecs.getincrementaldecoder("utf-8")(): он сохраняет недочитанные байты символа до следующего вызова.
Генераторы: конвейер обработки
Генераторы позволяют разбить обработку на маленькие шаги, при этом данные проходят через все шаги по одной записи и не накапливаются в памяти. Подробнее о механизме yield рассказано в статье про генераторы.
def read_lines(path):
with open(path, encoding="utf-8") as f:
for line in f:
yield line.rstrip("\n")
def only_errors(lines):
return (ln for ln in lines if " ERROR " in ln)
def parse(lines):
for ln in lines:
date, time, level, *msg = ln.split(" ", 3)
yield {"date": date, "level": level, "msg": " ".join(msg)}
pipeline = parse(only_errors(read_lines("app.log")))
for rec in pipeline:
print(rec["date"], rec["msg"][:60])
Каждая функция маленькая, тестируется отдельно, а потребление памяти не зависит от размера лога.
Обработка пачками
Вставлять в базу данных по одной строке медленно, а все сразу нельзя из-за памяти. Компромисс: пачки по несколько тысяч записей. В Python 3.12 для этого появилась функция itertools.batched:
import sqlite3
from itertools import batched
con = sqlite3.connect("logs.db")
con.execute("CREATE TABLE IF NOT EXISTS errors (date TEXT, level TEXT, msg TEXT)")
for batch in batched(parse(only_errors(read_lines("app.log"))), 5000):
con.executemany("INSERT INTO errors VALUES (:date, :level, :msg)", batch)
con.commit()
con.close()
Как посчитать количество строк в файле
Простой и экономный по памяти вариант:
with open("big.csv", encoding="utf-8") as f:
count = sum(1 for _ in f)
Он декодирует каждую строку и создает объект строки, поэтому на гигабайтах заметно медленнее, чем подсчет байтов перевода строки в бинарном режиме:
def count_lines(path, chunk_size=1 << 20):
count = 0
last = b""
with open(path, "rb") as f:
while chunk := f.read(chunk_size):
count += chunk.count(b"\n")
last = chunk
if last and not last.endswith(b"\n"):
count += 1 # последняя строка без перевода строки
return count
Сравнение способов подсчета строк на многогигабайтном файле:
| Способ | Скорость | Память |
|---|---|---|
len(f.readlines()) | медленно | больше размера файла |
sum(1 for _ in f), текстовый режим | медленно, тратится время на декодирование | несколько МБ |
sum(1 for _ in f), бинарный режим | быстрее, декодирования нет | несколько МБ |
chunk.count(b"\n") блоками по 1 МБ | самый быстрый, подсчет идет в коде на C | около 1 МБ |
Точные цифры зависят от диска и длины строк, поэтому замеряйте на своих данных через модуль timeit.
Большие CSV-файлы
Модуль csv из стандартной библиотеки работает потоково: csv.reader и csv.DictReader читают файл по одной записи. Главное правило: открывать файл с newline="", иначе поля с переводами строк внутри кавычек разберутся неправильно.
import csv
from collections import defaultdict
totals = defaultdict(float)
with open("sales.csv", encoding="utf-8", newline="") as f:
reader = csv.DictReader(f, delimiter=";")
for row in reader:
totals[row["region"]] += float(row["amount"].replace(",", "."))
for region, total in sorted(totals.items(), key=lambda kv: -kv[1])[:5]:
print(f"{region}: {total:,.0f}")
Память здесь зависит только от количества регионов, а не от числа строк. Группировка через defaultdict разобрана в статье про модуль collections, а для записи результата обратно в CSV подойдет csv.writer, который тоже работает построчно.
Pandas и параметр chunksize
Если нужна именно pandas, не загружайте файл целиком. Параметр chunksize возвращает итератор по кускам таблицы, а usecols и dtype сокращают память в разы:
import pandas as pd
parts = []
for chunk in pd.read_csv("sales.csv", sep=";", chunksize=500_000,
usecols=["region", "amount"],
dtype={"region": "category"}, decimal=","):
parts.append(chunk.groupby("region", observed=True)["amount"].sum())
result = pd.concat(parts).groupby(level=0).sum().nlargest(5)
print(result)
mmap: файл как массив байтов
Модуль mmap отображает файл в адресное пространство процесса. Python видит файл как большой объект bytes, но операционная система подгружает с диска только те страницы, к которым вы обращаетесь. Это удобно для поиска и произвольного доступа: найти подстроку в файле на 10 ГБ, прочитать байты по смещению, применить регулярное выражение.
import mmap
import re
with open("dump.sql", "rb") as f, mmap.mmap(f.fileno(), 0, access=mmap.ACCESS_READ) as mm:
pos = mm.find(b"CREATE TABLE users")
print(pos) # -> смещение в байтах или -1
if pos != -1:
end = mm.find(b";", pos)
print(mm[pos:end + 1].decode("utf-8"))
emails = set(re.findall(rb"[\w.+-]+@[\w-]+\.[\w.]+", mm))
print(len(emails))
Особенности, о которых стоит помнить:
- Пустой файл отобразить нельзя: будет
ValueError: cannot mmap an empty file. - Регулярные выражения для
mmapдолжны быть байтовыми (rb"..."). - Метод
mm.readline()позволяет читать строки, аmm.rfind()ищет с конца, что удобно для последних записей лога. - На 32-битном Python размер отображения ограничен примерно 2 ГБ, на 64-битном ограничения практически нет.
Для последовательного чтения от начала до конца mmap не быстрее обычного построчного цикла. Его сила в поиске и случайном доступе.
Кодировки и битые байты
Большие файлы чаще содержат "мусор": обрезанную последнюю строку, смесь кодировок, случайные бинарные вставки. Одна плохая последовательность байтов на 3-м гигабайте роняет всю обработку с UnicodeDecodeError. Параметр errors управляет этой ситуацией:
| Значение errors | Поведение |
|---|---|
"strict" (по умолчанию) | исключение на первом плохом байте |
"replace" | замена на символ U+FFFD, обработка продолжается |
"ignore" | плохие байты отбрасываются молча |
"surrogateescape" | байты сохраняются и восстанавливаются при записи |
with open("legacy.log", encoding="utf-8", errors="replace") as f:
bad = sum(1 for line in f if "�" in line)
print(f"строк с поврежденными символами: {bad}")
Если файл в cp1251, указывайте encoding="cp1251" явно. Если в начале может стоять BOM, используйте utf-8-sig. Когда нужен только поиск байтовой подстроки, надежнее и быстрее вообще не декодировать текст и работать в режиме "rb".
Память, сжатые файлы и последние строки
Напоследок три приема, которые регулярно нужны при работе с логами и выгрузками: замер памяти, чтение архивов на лету и быстрый доступ к концу файла.
Как проверить расход памяти
import tracemalloc
tracemalloc.start()
count = count_lines("big.csv")
current, peak = tracemalloc.get_traced_memory()
print(f"пик памяти: {peak / 1024 / 1024:.1f} МБ")
tracemalloc.stop()
Если пик растет вместе с размером файла, где-то данные накапливаются: в списке, в словаре или в строке через +=.
Чтение сжатых файлов без распаковки
Логи часто хранят в .gz. Модуль gzip распаковывает данные на лету, и построчное чтение работает так же, как с обычным файлом:
import gzip
with gzip.open("access.log.gz", "rt", encoding="utf-8") as f:
hits = sum(1 for line in f if "GET /api/" in line)
Режим "rt" обязателен: без буквы t вы получите байты.
Последние строки огромного файла
Читать 10 ГБ ради последних 20 строк незачем. Перейдите в конец через seek и прочитайте хвост:
import os
def tail(path, n=20, block=64 * 1024):
with open(path, "rb") as f:
f.seek(0, os.SEEK_END)
size = f.tell()
data = b""
while size > 0 and data.count(b"\n") <= n:
step = min(block, size)
size -= step
f.seek(size)
data = f.read(step) + data
return [ln.decode("utf-8", "replace") for ln in data.splitlines()[-n:]]
Функция читает файл с конца блоками по 64 КБ и останавливается, как только набрано нужное число строк.
Типичные ошибки при обработке больших файлов
Даже при правильном построчном чтении память может расти, а обработка замедляться. Причина почти всегда в том, что происходит внутри цикла.
- Накопление результата в списке. Цикл читает файл экономно, но каждую обработанную строку добавляет в
results. В итоге в памяти оказывается весь файл. Пишите результат сразу в выходной файл или агрегируйте его в счетчики. - Склейка строк через +=. Сборка большой строки в цикле создает новый объект на каждой итерации. Собирайте части в список и объединяйте один раз через
"".join(), а еще лучше пишите их сразу в файл. - Регулярное выражение компилируется в цикле. Модуль
reкэширует шаблоны, но вызов функции с поиском по кэшу на каждой из миллионов строк тоже стоит времени. Скомпилируйте шаблон один раз до цикла. - Лишняя работа на каждой строке. Проверка
if "ERROR" in lineвыполняется в десятки раз быстрее, чем разбор строки на поля. Сначала отфильтруйте дешевой проверкой, потом разбирайте только нужные строки. - Вывод в консоль на каждой строке. Вызов
printдля каждой записи может замедлить обработку сильнее, чем само чтение. Выводите прогресс раз в несколько сотен тысяч строк.
Если после всех оптимизаций обработка все равно медленная, замерьте, где тратится время, с помощью профилировщика cProfile. Часто оказывается, что узкое место вовсе не в чтении файла, а в преобразовании дат или обращениях к базе данных внутри цикла.
Чек-лист для больших файлов
- Не вызывайте
read()иreadlines()без аргументов на файлах неизвестного размера. - Текст читайте циклом
for line in f, бинарные данные блоками черезread(size). - Стройте обработку как цепочку генераторов, а результаты агрегируйте, а не накапливайте.
- Пишите в базу и файлы пачками.
- Для поиска по огромному файлу используйте
mmapили бинарный режим. - Всегда указывайте
encoding, а для грязных данных еще иerrors. - Для JSON на гигабайты переходите на формат JSON Lines или потоковый парсер.
Частые вопросы
Что быстрее: построчное чтение или чтение блоками?
Для подсчета символов или поиска байтовой подстроки блоки в бинарном режиме быстрее в несколько раз. Если же каждую строку все равно нужно разобрать, разница почти исчезает, а построчный код проще и надежнее.
Поможет ли увеличить buffering в open?
Немного. Параметр buffering=1024*1024 уменьшает число системных вызовов, но буфер по умолчанию уже достаточно велик. Заметный выигрыш дают отказ от декодирования, обработка в бинарном режиме и уменьшение работы внутри цикла.
Как прочитать большой JSON-файл?
json.load всегда загружает документ целиком. Для огромных массивов используйте потоковый парсер (пакет ijson) или храните данные в формате JSON Lines. Приемы работы с этим форматом описаны в статье о том, как записать и прочитать JSON-файл.
Можно ли читать большой файл в несколько процессов?
Да: разделите файл на диапазоны байтов, каждый процесс делает seek на начало своего диапазона, пропускает неполную первую строку и читает до конца диапазона. Это оправдано, когда узкое место в процессоре (разбор, вычисления), а не в скорости диска.
Как быстро прочитать первые N строк файла?
Через itertools.islice: list(islice(f, 10)) вернет 10 строк и не тронет остальной файл. Это быстрее и безопаснее, чем f.readlines()[:10].