Партиционирование в PostgreSQL — один из тех инструментов, к которому приходят поздно. Обычно тогда, когда таблица уже весит сотни гигабайт, запросы тормозят, а DELETE на миллионах строк превращается в часовой ритуал. Разбираем, как работает PARTITION BY, когда секционирование реально помогает, а когда лучше не трогать.

Когда партиционирование реально помогает
Прежде чем лезть в настройки, стоит ответить на один вопрос: есть ли в таблице естественный «срез», по которому идут запросы? Если нет — партиционирование не поможет.
Ощутимый эффект оно даёт в трёх случаях. Первый — таблица логов или транзакций, где большинство запросов смотрят на последние N дней: PostgreSQL читает только нужную секцию, а не пробегает всю таблицу. Второй — нужно регулярно выбрасывать старые данные: DROP PARTITION занимает секунды там, где массовый DELETE идёт часами. Третий — данные сами по себе делятся по чёткому признаку: региону, типу события, статусу заказа — и такое деление устойчиво.
Если таблица небольшая или запросы равномерно распределены по всему объёму — партиционирование только добавит сложности без выигрыша в скорости.
Три стратегии: RANGE, LIST, HASH
Выбор стратегии определяет, как PostgreSQL физически разделяет строки. Их три, и каждая под свой сценарий:
- RANGE — делит по диапазону значений. Классика для дат: каждый месяц или год уходит в отдельную секцию. Чаще всего встречается в проектах с логами и транзакциями.
- LIST — каждой секции явно назначается набор конкретных значений ключа. Удобно при делении по коду страны, типу документа или статусу заказа.
- HASH — никакого смыслового деления нет. PostgreSQL сам вычисляет, в какую секцию попадёт строка, просто чтобы равномерно распределить объём.
HASH выбирают, когда всё остальное не подходит. Но partition pruning с ним работает только при точном совпадении значения, так что основное его преимущество — параллельная запись, а не ускорение чтения.
Минимальный рабочий пример
Покажу на таблице событий. Синтаксис декларативного партиционирования выглядит так: сначала объявляем корневую таблицу с PARTITION BY, потом дочерние — с указанием границ. Корневая таблица сама строк не хранит, она только маршрутизирует.
CREATE TABLE events (
id BIGSERIAL,
user_id BIGINT NOT NULL,
event_type TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL,
payload JSONB
) PARTITION BY RANGE (created_at);
CREATE TABLE events_2025_01
PARTITION OF events
FOR VALUES FROM ('2025-01-01') TO ('2025-02-01');
CREATE TABLE events_2025_02
PARTITION OF events
FOR VALUES FROM ('2025-02-01') TO ('2025-03-01');
Индекс объявляется один раз на корневой — PostgreSQL сам разложит его по дочерним:
CREATE INDEX ON events (created_at);
Почему pruning может не сработать — и как это поймать
Partition pruning — это когда планировщик заранее выбрасывает из плана секции, куда запрос точно не попадёт. Звучит просто, но ломается неожиданно. Проверить легко:
EXPLAIN (ANALYZE, BUFFERS)
SELECT * FROM events
WHERE created_at >= '2025-01-01'
AND created_at < '2025-02-01';
Если в плане видна только одна секция — всё работает. Если PostgreSQL честно обошёл все партиции подряд — pruning отключился. Самая частая причина, с которой я сталкивался: тип параметра в условии не совпадает с типом ключа. Передаёшь строку там, где ждут timestamptz — и база на всякий случай проверяет каждую секцию. Лечится явным кастом в запросе или на уровне ORM.

pg_partman: чтобы не создавать секции руками
Делать CREATE TABLE ... PARTITION OF вручную каждый месяц — плохая идея. Забудешь один раз, и вставки начнут падать с ошибкой про отсутствующую секцию. Это неприятно обнаруживать в три часа ночи.
pg_partman решает проблему: регистрируешь таблицу один раз, указываешь интервал и горизонт — дальше расширение само держит нужный запас секций вперёд.
CREATE EXTENSION pg_partman;
SELECT partman.create_parent(
p_parent_table => 'public.events',
p_control => 'created_at',
p_interval => '1 month',
p_premake => 3
);
p_premake = 3 — это три секции «в запасе» от сегодняшней даты. Важный нюанс: расширение само по себе ничего не делает по расписанию. Его нужно дёргать через pg_cron или внешний cron. Без этого шага оно просто будет ждать.
Три вещи, которые удивляют при первом знакомстве
PRIMARY KEY должен содержать ключ партиционирования — иначе ошибка при создании. Звучит странно, но логика понятна: уникальность PostgreSQL может проверить только внутри одной секции, глобальную гарантию дать не может.
С внешними ключами на партиционированную таблицу долго было плохо — вплоть до 15-й версии они попросту не работали. В 16-й добавили частичную поддержку, но «частичную» — ключевое слово. Если FK важен для логики, лучше проверить на своей версии руками, а не верить что «в новых версиях всё хорошо».
И самое болезненное — нельзя взять существующую таблицу и «включить» для неё партиционирование. Нужно создать новую схему, перелить данные, переключить приложение. На больших объёмах это нетривиальная операция с риском даунтайма. Именно поэтому секционирование стоит закладывать в схему заранее, а не когда таблица уже выросла до неудобных размеров.
Партиционирование — не таблетка, а скальпель
Секционирование — не универсальный ускоритель, а инструмент с чётко очерченной областью применения. Оно хорошо работает там, где запросы предсказуемо «приземляются» в конкретный диапазон, а данные за пределами этого диапазона либо не нужны, либо периодически выбрасываются. Если запросы случайны по всей таблице — partition pruning не сработает, и вы получите лишь накладные расходы на маршрутизацию строк. Разумная точка входа: сначала индексы и профилирование через EXPLAIN ANALYZE, и только когда они перестают справляться — партиционирование как следующий уровень.