← назад к разделу

Представьте таблицу событий, которая накапливает по несколько миллионов строк в день. Через год в ней миллиарды записей. Запросы замедляются, автоматическая очистка не успевает, а удаление старых данных через DELETE превращается в часовую операцию с огромной нагрузкой на диск. Это именно та ситуация, для которой создано партиционирование.

снаружи одна таблица, внутри — файл на каждый месяц event_log PARTITION BY RANGE (occurred_at) …_2026_04 апрель …_2026_05 май …_2026_06 июнь …_2026_07 июль · пустая 2026-06-14новая строка…_2026_06июнь WHERE occurred_at >= '2026-06-01' AND occurred_at < '2026-07-01'открывается одна партиция, остальные файлы даже не трогаются файла больше нетDROP TABLE …_2026_04 — минус целый файл, мёртвых версий не остаётся

Строку раскладывает по партициям сам PostgreSQL: смотрит на occurred_at и кладёт её в файл нужного месяца. Запрос с условием по тому же ключу открывает только один файл — это и есть partition pruning. А удаление старого месяца — не DELETE на миллионы строк, а DROP TABLE одной партиции.

Обязательно

Что такое партиционирование

Партиционирование — это разделение одной большой таблицы на несколько физических частей (партиций) по заданному правилу. Снаружи вы продолжаете работать с одной таблицей: делаете INSERT, SELECT, UPDATE как обычно. PostgreSQL сам решает, в какую партицию записать строку и какие партиции просматривать при запросе.

Когда вы пишете:

SELECT * FROM event_log WHERE occurred_at >= '2026-05-01' AND occurred_at < '2026-06-01';

PostgreSQL видит, что запрос касается только мая, и читает только одну партицию event_log_2026_05. Остальные месяцы физически не трогаются. Это называется partition pruning (отсечение партиций).

Когда партиционирование оправдано

Партиционирование решает конкретные проблемы. Без проблемы — инструмент не нужен.

Стоит рассмотреть, если:

  • Таблица больше 50 GB (или 100 миллионов строк) и продолжает расти.
  • Это данные типа time-series: события, метрики, логи — где у каждой строки есть временная метка.
  • Старые данные нужно регулярно удалять: за прошлый месяц, за прошлый год.
  • Автоочистка (autovacuum) не справляется с таблицей — постоянно отстаёт.

Лишнее, если:

  • Таблица меньше 10 GB. Обычные индексы справятся лучше и без сложности.
  • Запросы редко используют предполагаемый ключ партиционирования в WHERE. Тогда отсечения партиций не будет — только накладные расходы.
  • Данные распределены абсолютно равномерно и запросы читают всё подряд — это не партиционирование, это скорее шардирование.

Декларативное партиционирование

С PostgreSQL 10 появился удобный синтаксис. Сначала создаёте «родительскую» таблицу с указанием типа партиционирования, затем добавляете партиции:

CREATE TABLE event_log (
    id          bigint GENERATED ALWAYS AS IDENTITY,
    occurred_at timestamptz NOT NULL,
    payload     jsonb NOT NULL,
    PRIMARY KEY (id, occurred_at)
) PARTITION BY RANGE (occurred_at);

CREATE TABLE event_log_2026_05 PARTITION OF event_log
    FOR VALUES FROM ('2026-05-01') TO ('2026-06-01');

CREATE TABLE event_log_2026_06 PARTITION OF event_log
    FOR VALUES FROM ('2026-06-01') TO ('2026-07-01');

Обратите внимание: первичный ключ включает occurred_at — поле партиционирования. Это обязательное требование PostgreSQL. Без этого создать PK не получится.

После этого всё работает прозрачно:

-- INSERT идёт в родительскую таблицу, PostgreSQL сам кладёт в нужную партицию
INSERT INTO event_log (occurred_at, payload) VALUES (now(), '{"type":"click"}');

-- SELECT с отсечением партиций — читается только event_log_2026_05
SELECT * FROM event_log
WHERE occurred_at >= '2026-05-15' AND occurred_at < '2026-05-20';

Три типа партиционирования

RANGE — для временных данных

Самый распространённый тип. Каждая партиция отвечает за диапазон значений — например, один месяц или один год.

PARTITION BY RANGE (occurred_at);

Подходит для time-series, архивов по периодам, числовых шкал.

LIST — для категорий

Когда данные делятся по фиксированному набору значений: регион, тип документа, идентификатор клиентской группы.

CREATE TABLE order_doc (...) PARTITION BY LIST (region);

CREATE TABLE order_doc_eu    PARTITION OF order_doc FOR VALUES IN ('EU', 'UK');
CREATE TABLE order_doc_usa   PARTITION OF order_doc FOR VALUES IN ('US', 'CA');
CREATE TABLE order_doc_other PARTITION OF order_doc DEFAULT;

Секция DEFAULT принимает все строки, которые не попали ни в одну из именованных партиций. Без неё INSERT с неизвестным значением упадёт с ошибкой.

HASH — равномерное распределение

Партиции определяются по остатку от деления хэша поля на число партиций.

PARTITION BY HASH (user_id);

CREATE TABLE user_event_p0 PARTITION OF user_event FOR VALUES WITH (MODULUS 4, REMAINDER 0);
CREATE TABLE user_event_p1 PARTITION OF user_event FOR VALUES WITH (MODULUS 4, REMAINDER 1);
-- и т.д.

Используется редко. HASH не даёт отсечения при диапазонных запросах — только при точном совпадении (WHERE user_id = ?). Он помогает ровно размазать нагрузку на запись, и этим польза почти исчерпывается.

Как проверить, что отсечение работает

Вся ценность разбиения — в том, что запрос читает одну часть вместо всей таблицы. Проверяется это единственным способом — планом запроса.

EXPLAIN (ANALYZE, BUFFERS)
SELECT count(*) FROM event_log
WHERE occurred_at >= DATE '2026-08-01' AND occurred_at < DATE '2026-09-01';

В плане должен остаться один узел Seq Scan on event_log_2026_08 (или Index Scan по её индексу). Если вместо этого видно Append со списком всех частей — отсечение не сработало, и причин обычно три: в условии нет ключа разбиения, ключ завёрнут в функцию (date_trunc('month', occurred_at) = …) или сравнивается с другим типом, и база не может сопоставить границы.

Отдельный случай, на котором делают неверный вывод, — отсечение во время выполнения. Когда граница приходит параметром (WHERE occurred_at >= $1) или берётся из другого узла плана (соединение, подзапрос), база не может отсечь части на этапе планирования: она ещё не знает значения. Отсечение происходит уже при выполнении, и в обычном EXPLAIN его не видно — там честно перечислены все части. Видно его только в EXPLAIN ANALYZE, по строке вида:

Append (actual rows=... loops=1)
  Subplans Removed: 11

Subplans Removed и означает «одиннадцать частей были отброшены на лету». Без ANALYZE этой строки нет, и люди решают, что разбиение не работает, хотя оно работает.

Как выбрать ключ партиционирования

Выбор ключа — самое важное решение при партиционировании.

Правило одно: ключ должен быть в WHERE почти всех запросов к этой таблице.

Хорошие примеры:

  • Таблица событий с WHERE occurred_at > ? → ключ occurred_at.
  • Многопользовательская система, где каждый запрос идёт в контексте одного клиента → ключ tenant_id.
  • Архив заказов с запросами за период → ключ created_at.

Плохие примеры:

  • Партиционировать orders по status, если большинство запросов фильтрует по customer_id — отсечения не будет, PostgreSQL будет читать все партиции.
  • Партиционировать customers по country, если 90% записей в одной стране — одна партиция будет огромной, остальные почти пустыми.

Перед созданием партиций полезно посмотреть, как строки ложатся по месяцам. Запрос одинаковый для любой таблицы — вот он на заказах:

живой пример

SELECT date_trunc('month', created_at) AS month,
       count(*) AS cnt
FROM orders
GROUP BY month
ORDER BY month;
Запустить

Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →

Если данные распределены неравномерно — переосмыслите ключ.

Размер партиций

Ориентируйтесь на 1–50 GB на партицию. Для time-series это означает:

  • Помесячные партиции: при нескольких миллионах событий в месяц.
  • Понедельные: при десятках миллионов событий в неделю.
  • Подневные: при сотнях миллионов событий в день.

Тысячи партиций раньше были приговором: начиная с PostgreSQL 12 планировщик быстро отсекает ненужные — но только если в запросе есть условие по ключу партиционирования и отсечение действительно работает. Если его нет, база вынуждена открыть и заблокировать все партиции разом, и на нескольких тысячах это уже заметно: растёт время планирования и расход памяти на блокировки. Если нужны подневные партиции за несколько лет, заранее думайте об архивировании.

Управление партициями

Создавайте партиции заранее

Если строка попадает в таблицу, а нужной партиции ещё нет — INSERT упадёт с ошибкой (если нет DEFAULT партиции). Заводите партиции на 1–2 периода вперёд.

CREATE TABLE event_log_2026_07 PARTITION OF event_log
    FOR VALUES FROM ('2026-07-01') TO ('2026-08-01');

Мгновенное удаление старых данных — главный плюс

Вот ради чего обычно и партиционируют time-series данные:

DROP TABLE event_log_2025_05;   -- мгновенно: файл удаляется целиком

Сравните с обычным удалением:

DELETE FROM event_log WHERE occurred_at < '2026-01-01';

При миллионах строк этот DELETE:

  • Ищет строки по индексу, а без подходящего индекса — полным сканом.
  • Оставляет столько же мёртвых версий строк: физически PostgreSQL их сразу не убирает.
  • Требует автоочистки, чтобы место снова стало пригодным для записи.
  • Может занять десятки минут с высокой нагрузкой на диск и WAL.

DROP TABLE партиции удаляет файл с диска мгновенно — не читая строки и не создавая мёртвых версий, которые потом пришлось бы убирать. Одна оговорка: чтобы отцепить партицию от родителя, команде нужна исключительная блокировка на всю таблицу, и она встанет в очередь за любым долгим запросом — а следом за ней встанут все остальные. На нагруженной базе безопаснее сначала ALTER TABLE ... DETACH PARTITION CONCURRENTLY, а уже потом удалять отцепленную таблицу.

Статистику родительской таблицы собирают руками

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

Пока запросы попадают в одну часть, это незаметно: планировщик берёт её собственную статистику. Но запрос, который идёт по нескольким частям или по родителю целиком, планируется вслепую — с оценками по умолчанию, из которых получаются неудачные соединения и неверный выбор индексов.

Лечится явным анализом родителя, поставленным в регламент:

ANALYZE event_log;

Его добавляют в то же ночное задание, которое создаёт новые части и отцепляет старые, — раз в сутки достаточно. Поведение стоит перепроверить на своей мажорной версии: детали здесь от версии к версии уточняются, а цена ошибки — плохие планы на самых тяжёлых запросах.

Частые ошибки

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

Менять значение поля партиционирования через UPDATE. PostgreSQL фактически удаляет строку из одной партиции и вставляет в другую — это неэффективно и может вызвать неожиданные блокировки. Ключ партиционирования должен быть неизменяемым.

Соединения и агрегаты по частям

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

enable_partitionwise_join разрешает соединять две одинаково нарезанные таблицы по частям: август с августом, сентябрь с сентябрём, вместо того чтобы сливать их целиком и соединять одной большой операцией. Условие — одинаковый ключ и совпадающие границы частей у обеих таблиц.

enable_partitionwise_aggregate делает то же для группировки: считает итоги по каждой части отдельно и потом складывает. Особенно заметно на GROUP BY по ключу разбиения — тогда складывать даже нечего.

SET enable_partitionwise_join = on;
SET enable_partitionwise_aggregate = on;

Выключены они не случайно: на таблицах с десятками частей планирование само по себе становится дороже, потому что вариантов больше. Поэтому их включают осознанно — на уровне сессии для тяжёлых отчётов или глобально, если разбиение есть у большинства больших таблиц. И проверяют результат тем же EXPLAIN: в плане должны появиться соединения внутри Append, а не одно большое соединение поверх него.

Чего партиционированная таблица не умеет

Несколько ограничений, о которые спотыкаются при переходе.

Внешний ключ на партиционированную таблицу (то есть когда она родительская сторона связи) появился только в PostgreSQL 12. На более старых версиях сослаться на такую таблицу нельзя вовсе — это регулярная причина, по которой переход откладывают.

INSERT … ON CONFLICT требует уникального индекса, а уникальный индекс на партиционированной таблице обязан включать ключ разбиения. То есть «уникальный email на всю таблицу» при разбиении по дате недостижим: уникальность получится только в пределах части. Это не обходится настройкой — это следствие устройства, и либо уникальность переносят на другой уровень (отдельная таблица-справочник с ключами), либо разбивают по тому, по чему нужна уникальность.

Перерезать часть, которая выросла, нельзя без переноса данных: границы части неизменны. Нужный ход — создать новые, более мелкие части, перелить в них данные и отцепить старую. Поэтому ориентир «одна часть в пределах десятков гигабайт» выбирают с запасом на рост: он взят из практики обслуживания (уборка, перестройка индексов и резервное копирование одной части должны укладываться в окно обслуживания), а не из какого-то ограничения базы.

Разбиение в два уровня

Часть сама может быть разбита дальше — по другому ключу. Классика: месяц, а внутри месяца регион.

CREATE TABLE event_log_2026_08 PARTITION OF event_log
    FOR VALUES FROM ('2026-08-01') TO ('2026-09-01')
    PARTITION BY LIST (region);

CREATE TABLE event_log_2026_08_eu PARTITION OF event_log_2026_08 FOR VALUES IN ('EU');
CREATE TABLE event_log_2026_08_us PARTITION OF event_log_2026_08 FOR VALUES IN ('US');

Выигрыш — отсечение сразу по двум условиям. Цена — рост числа частей по произведению: двенадцать месяцев на пять регионов это шестьдесят таблиц, и каждая со своими индексами, своей уборкой и своим местом в планировании. Второй уровень заводят, когда второй ключ действительно есть почти в каждом запросе; в остальных случаях дешевле обычный индекс по региону внутри месячной части.

Дополнительно: при первом чтении можно пропустить

Глубже: Механика партиций на маленькой программерасширенное

Отсечение партиций из первого раздела проще всего пощупать без PostgreSQL.

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

живой пример

import java.time.LocalDate;
import java.time.YearMonth;
import java.util.List;
import java.util.Map;
import java.util.TreeMap;

public class MonthlyPartitions {
    static String partitionOf(LocalDate day) {
        return "event_log_" + YearMonth.from(day).toString().replace('-', '_');
    }

    public static void main(String[] args) {
        List<LocalDate> rows = List.of(
                LocalDate.parse("2026-04-03"), LocalDate.parse("2026-05-11"),
                LocalDate.parse("2026-06-14"), LocalDate.parse("2026-06-29"),
                LocalDate.parse("2026-07-02"));
        Map<String, Integer> partitions = new TreeMap<>();
        for (LocalDate row : rows) {
            partitions.merge(partitionOf(row), 1, Integer::sum);
        }
        System.out.println("строки разложены: " + partitions);

        String from = partitionOf(LocalDate.parse("2026-06-01"));
        String to = partitionOf(LocalDate.parse("2026-06-30"));
        for (String name : partitions.keySet()) {
            boolean read = name.compareTo(from) >= 0 && name.compareTo(to) <= 0;
            System.out.println(name + (read ? " — читается" : " — пропущена"));
        }

        String oldest = partitions.keySet().iterator().next();
        int gone = partitions.remove(oldest);
        System.out.println("DROP TABLE " + oldest + " — строк внутри: " + gone + ", файл удалён целиком");
    }
}
Запустить

Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →

Глубже: Цена DEFAULT-партициирасширенное

Речь о секции DEFAULT, которая в LIST-партиционировании принимает строки без своей партиции.

Страховка удобная, но у неё две цены, и обе всплывают не сразу.

Первая: пока в DEFAULT что-то лежит, добавление новой партиции перестаёт быть мгновенным. PostgreSQL обязан убедиться, что в DEFAULT не завалялось строк, которые теперь должны были бы попасть в новую партицию, — а для этого надо прочитать её целиком, под жёсткой блокировкой. Если туда год копились «прочие» регионы, очередной ATTACH PARTITION встанет надолго, а найдя такую строку, просто откажется:

ERROR:  updated partition constraint for default partition "order_doc_other"
        would be violated by some row

Вторая: пока у таблицы есть DEFAULT-партиция — даже пустая, — не работает DETACH PARTITION ... CONCURRENTLY, о котором речь ниже. Придётся либо отцеплять партиции обычной командой с жёсткой блокировкой, либо на время удалять DEFAULT.

Поэтому DEFAULT держат как сигнализацию, а не как склад: ставят оповещение на любые строки внутри и разбирают их, а не копят.

Глубже: Автоматизация через pg_partmanрасширенное

Заводить партиции на периоды вперёд, как в разделе про управление, — рутина по календарю.

Автоматизировать это можно через расширение pg_partman:

CREATE SCHEMA partman;
CREATE EXTENSION pg_partman SCHEMA partman;

SELECT partman.create_parent(
    p_parent_table => 'public.event_log',
    p_control      => 'occurred_at',
    p_interval     => '1 month',
    p_type         => 'range',   -- в pg_partman 4 на этом месте стояло 'native'
    p_premake      => 4          -- создать 4 партиции наперёд
);

-- вызывается по расписанию (cron или pg_cron)
SELECT partman.run_maintenance('public.event_log');

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

Глубже: Отделение партиции без удалениярасширенное

Иногда старые данные нужно не удалить, а перенести — в архивную таблицу или другое хранилище:

ALTER TABLE event_log DETACH PARTITION event_log_2025_05;
-- теперь event_log_2025_05 — обычная самостоятельная таблица

В PostgreSQL 14 появился вариант, который не берёт исключительную блокировку на родителя, — но выполнять его можно только вне транзакционного блока:

ALTER TABLE event_log DETACH PARTITION event_log_2025_05 CONCURRENTLY;

Работает он в два этапа, и это важно: если сессию оборвали между ними, партиция остаётся в промежуточном состоянии — уже не полноценная часть таблицы, но ещё и не самостоятельная. Новые ALTER TABLE по этой таблице будут отказывать, пока отцепление не довести до конца:

ALTER TABLE event_log DETACH PARTITION event_log_2025_05 FINALIZE;

Проверить, не зависло ли что-то, можно по системному каталогу:

SELECT inhrelid::regclass AS partition, inhdetachpending
FROM pg_inherits WHERE inhparent = 'event_log'::regclass;

Если inhdetachpending везде false — добивать нечего, FINALIZE в таком случае ответит There's no pending concurrent detach.

Только помните про оговорку из раздела про DEFAULT: если у таблицы есть DEFAULT-партиция, щадящий вариант недоступен вовсе:

ERROR:  cannot detach partitions concurrently when a default partition exists

Выбирать приходится: либо DEFAULT как страховка от неожиданных значений, либо отцепление партиций без остановки записи. Для time-series, где партиции по датам заводятся наперёд и неожиданных значений не бывает, обычно отказываются от DEFAULT.

Глубже: Индексы на партиционированных таблицахрасширенное

Индекс, созданный на родительской таблице, автоматически создаётся на каждой партиции:

-- выражение берут в двойные скобки: одиночные PostgreSQL примет за имя колонки
CREATE INDEX ON event_log ((payload->>'event_type'));
-- PostgreSQL создаёт индекс на event_log_2026_05, event_log_2026_06 и т.д.

Удобно — и ровно здесь ломается правило «индексы всегда CONCURRENTLY». На партиционированной таблице такой команды просто нет:

CREATE INDEX CONCURRENTLY ix_p ON event_log (occurred_at);
ERROR:  cannot create index on partitioned table "event_log" concurrently

То есть индекс на родителе строится обычной командой, а она берёт жёсткую блокировку и не пускает запись во все партиции сразу, пока не построится последняя. На живой таблице в несколько сотен гигабайт это неприемлемо.

Рабочая последовательность — в три шага:

-- 1. Пустой индекс только на родителе: ON ONLY, мгновенно
CREATE INDEX ix_event_log_type ON ONLY event_log ((payload->>'event_type'));

-- 2. По каждой партиции — уже CONCURRENTLY, по одной, без блокировок
CREATE INDEX CONCURRENTLY ix_event_log_2026_05_type
    ON event_log_2026_05 ((payload->>'event_type'));
CREATE INDEX CONCURRENTLY ix_event_log_2026_06_type
    ON event_log_2026_06 ((payload->>'event_type'));

-- 3. Подцепить готовые индексы к родительскому
ALTER INDEX ix_event_log_type ATTACH PARTITION ix_event_log_2026_05_type;
ALTER INDEX ix_event_log_type ATTACH PARTITION ix_event_log_2026_06_type;

Индекс на родителе считается недостроенным (indisvalid = false в pg_index), пока к нему не подцеплены индексы всех партиций; как только подцеплён последний — становится рабочим. Проверить можно так:

SELECT indexrelid::regclass, indisvalid
FROM pg_index WHERE indrelid = 'event_log'::regclass;

Для уникальных индексов есть ограничение: они обязаны включать ключ партиционирования. Тогда уникальность держится по всей таблице — строки с одинаковым ключом в любом случае попадут в одну и ту же партицию. А вот уникальности по одному только id или UUID, без ключа партиционирования, PostgreSQL не даст: под неё придётся завести отдельную справочную таблицу.

Глубже: Миграция существующей таблицы в партиционированнуюрасширенное

Если таблица уже существует и разрослась, её можно переместить в партиционированную структуру. Рецепт ниже — только для остановленной записи: в окно обслуживания, на таблицу, в которую в этот момент никто не пишет. Почему так — сразу после кода.

-- 1. Создать новую партиционированную таблицу
-- INCLUDING ALL перенесёт и первичный ключ: он обязан содержать occurred_at
CREATE TABLE event_log_new (LIKE event_log INCLUDING ALL)
    PARTITION BY RANGE (occurred_at);
-- Создать партиции...

-- 2. Скопировать данные
INSERT INTO event_log_new SELECT * FROM event_log;

-- 3. Переименовать атомарно
BEGIN;
ALTER TABLE event_log RENAME TO event_log_old;
ALTER TABLE event_log_new RENAME TO event_log;
COMMIT;

DROP TABLE event_log_old;

Для таблицы в 500 GB копирование займёт несколько часов, и потребуется место под обе версии одновременно. Но главная проблема не в месте.

Шаг 2 молча теряет данные, если запись не остановлена. INSERT ... SELECT копирует срез на момент своего начала. Всё, что приложение запишет в event_log за те несколько часов, пока идёт копирование, попадёт в старую таблицу — а на шаге 3 вы переименуете на её место новую, без этих строк. Потери никто не заметит: ошибок не будет, просто в таблице не хватает куска за полдня.

Шаг 2 ещё и сам по себе вредная операция. Это одна транзакция на несколько часов: она держит блокировки, копит журнал и мешает уборке мёртвых строк во всей базе — ровно то, чего избегают при миграциях без даунтайма. Копировать надо порциями, с коммитом между ними:

-- по месяцу за раз, каждый месяц — отдельная транзакция
INSERT INTO event_log_new
SELECT * FROM event_log
WHERE occurred_at >= '2026-05-01' AND occurred_at < '2026-06-01';

Если запись останавливать нельзя, порядок другой и он длиннее: приложение начинает писать сразу в обе таблицы, старые данные переносятся порциями в фоне, и только когда новая догнала старую, чтение переключают на неё. Это тот же expand-contract, что и в статье про миграции, и занимает он несколько релизов, а не одну ночь.

Коротко

  • Партиционирование — одна логическая таблица, разрезанная на физические файлы по заданному правилу; снаружи INSERT, SELECT и UPDATE не меняются, PostgreSQL сам кладёт строку в нужную партицию и при запросе с условием по ключу открывает только её.
  • Стоит партиционировать от 50 GB (100 миллионов строк) при регулярном удалении старых периодов и данных вида time-series; меньше 10 GB — обычные индексы справятся лучше и без сложности.
  • Ключ партиционирования обязан стоять в WHERE большинства запросов, иначе отсечения не будет; менять ключ через UPDATE нельзя. Сработало ли отсечение, показывает план: один узел вместо Append со всеми частями, а по параметру запроса оно идёт на лету и видно только в EXPLAIN ANALYZE строкой Subplans Removed.
  • Три типа: RANGE по датам — основной, LIST по фиксированному набору значений, HASH — только ровная запись и отсечение по точному совпадению.
  • Первичный ключ и любой уникальный индекс обязаны включать ключ партиционирования (поэтому «уникальный email на всю таблицу» при разбиении по дате недостижим). CREATE INDEX CONCURRENTLY на партиционированной таблице не работает: индекс заводят через ON ONLY на родителе, CONCURRENTLY по каждой части и ALTER INDEX ... ATTACH PARTITION.
  • DEFAULT-партиция — страховка с ценой: пока она есть, недоступен DETACH PARTITION ... CONCURRENTLY, а пока она непуста, добавление новой партиции сканирует её под жёсткой блокировкой; для time-series от неё обычно отказываются.
  • Главный плюс для time-series: DROP TABLE старой партиции мгновенен, а DELETE миллионов строк тянется десятки минут и оставляет мёртвые версии; на нагруженной базе сначала DETACH PARTITION CONCURRENTLY, потом DROP.
  • Целевой размер партиции 1–50 GB; их заводят на 1–2 периода вперёд (иначе INSERT упадёт), а pg_partman делает это по расписанию — в то же ночное задание ставят ANALYZE родителя, потому что сводную статистику автоанализ не собирает.
  • Переезд существующей таблицы через INSERT ... SELECT и переименование — только при остановленной записи; на живой таблице — двойная запись и перенос порциями, тот же expand-contract, что в миграциях без даунтайма.
  • enable_partitionwise_join и enable_partitionwise_aggregate выключены по умолчанию — без них соединение и группировка не разбиваются по частям; выросшую часть без переноса данных не перерезать.

Что почитать дальше