Все материалы
Продуктовая аналитикагайдсредний

Массивы в PostgreSQL: ARRAY_AGG, UNNEST, ANY и поиск по тегам

Массивы в PostgreSQL на примерах: собрать значения группы через array_agg, развернуть через unnest, первые N элементов, проверки ANY, @> и &&, string_to_array, NULL и аналоги в других СУБД.

КейсПрактика31 октября 2026 г.9 мин

Массив в PostgreSQL — это список значений внутри одной ячейки: теги статьи, тарифы, которые оплачивал пользователь, первые события его сессии. Аналитику массивы нужны в двух направлениях. Собрать: свернуть строки группы в один список и посмотреть на него целиком — например, путь пользователя по тарифам. Развернуть: превратить список из ячейки обратно в строки, чтобы посчитать, сколько раз встречается каждый тег. Ниже — обе операции, срезы, проверки на вхождение, перевод строки с разделителями в массив и ловушки с NULL. Все запросы на учебной базе выполняются и в PostgreSQL, и в песочнице.

Коротко

  • array_agg(x order by …) собирает значения группы в массив в заданном порядке.
  • unnest(массив) разворачивает массив обратно в строки, with ordinality добавляет номер элемента.
  • Нумерация элементов начинается с единицы: arr[1] — первый элемент, arr[1:3] — первые три.
  • Проверки: x = any(arr) — есть ли значение, arr @> array[…] — есть ли все, arr && array[…] — есть ли хотя бы одно.
  • array_to_string и string_to_array переводят массив в строку с разделителем и обратно.

Что такое массив и как его записать

Тип массива пишется как тип элемента со скобками: text[], integer[]. Все элементы одного массива — одного типа. Записать массив в запросе можно конструктором array['sql', 'join'] или строкой '{sql,join}'::text[] — так PostgreSQL и показывает массивы в результате.

Главное отличие от большинства языков программирования: индексы начинаются с единицы, а не с нуля. Срез arr[2:3] включает обе границы — второй и третий элементы. Обращение к несуществующему индексу не вызывает ошибку, а возвращает NULL.

Функции и операторы массивов, которые нужны чаще всего
ЗаписьЧто делаетПример → результат
array_agg(x order by y)собирает значения группы в массивтри строки → {basic,pro,pro}
unnest(arr)разворачивает массив в строки{a,b} → две строки
arr[1], arr[1:3]элемент и срез{a,b,c}[2] → b
array_length(arr, 1)длина массива{a,b,c} → 3
x = any(arr)есть ли значение в массиве'b' = any({a,b}) → true
arr @> array[…]содержит все перечисленные{a,b,c} @> {a,c} → true
arr && array[…]пересекается хотя бы в одном{a,b} && {b,c} → true
array_position(arr, x)номер первого вхождения{basic,pro,team}, pro → 2
array_to_string(arr, ', ')массив в строку{a,b} → a, b
string_to_array(s, ',')строку в массив'a,b' → {a,b}
array_append, array_removeдобавить или убрать элемент{1,2} + 3 → {1,2,3}

ARRAY_AGG: собрать значения группы

array_agg — агрегатная функция, как sum или count, только результат не число, а список. Порядок элементов задаётся внутри скобок: array_agg(plan order by paid_at). Без order by порядок не гарантирован, и два запуска одного запроса могут дать разные списки — для пути пользователя это недопустимо. Если у двух строк одинаковое время, добавьте второй ключ сортировки, например id платежа.

Запрос ниже собирает для каждого плательщика с двумя и более платежами последовательность тарифов и считает, какие последовательности встречаются чаще. Массивы можно группировать как обычные значения: два одинаковых списка попадают в одну группу.

Результат на учебной базе
plans_pathusers
basic → basic138
pro → pro77
basic → basic → basic27
team → team26
pro → pro → pro15
team → team → team7
Последовательности тарифов у пользователей с двумя и более платежами
with paths as (
  select
    user_id,
    array_agg(plan order by paid_at, payment_id) as plans
  from payments
  group by user_id
  having count(*) >= 2
)
select
  array_to_string(plans, ' → ') as plans_path,
  count(*)                      as users
from paths
group by plans
order by users desc, plans_path
limit 8;

Находка: в учебном продукте никто не меняет тариф

Все пути в результате выше однородные: basic продлевает basic, pro продлевает pro. Это легко проверить на всех плательщиках сразу: сравнить первый элемент массива с последним. Если тариф хоть раз менялся, они разойдутся.

Из 912 плательщиков 296 хоть раз платили за pro, и ни один не перешёл между тарифами. Для продукта это вопрос, который стоит задать: апгрейда с basic на pro нет вовсе. Возможно, в интерфейсе нет понятного пути к нему, а может быть, данные о смене тарифа пишутся в другую таблицу. Массив здесь не ответ, а быстрый способ заметить, что вопрос есть.

Результат на учебной базе
payersever_prochanged_plan
9122960
Сколько плательщиков хоть раз меняли тариф
with paths as (
  select
    user_id,
    array_agg(plan order by paid_at, payment_id) as plans
  from payments
  group by user_id
)
select
  count(*)                                                          as payers,
  count(*) filter (where 'pro' = any(plans))                        as ever_pro,
  count(*) filter (where plans[1] <> plans[array_length(plans, 1)]) as changed_plan
from paths;

Первые N элементов: срез массива

Срез [1:3] оставляет первые три элемента — например, первые три действия пользователя в продукте. Это короткий способ увидеть, как начинается знакомство с продуктом, без оконных функций и самосоединений.

Самое частое начало в учебной базе — открыть приложение, создать рабочее пространство и сразу отчёт: так начинали 1 775 из 4 613 пользователей. Второе по частоте — три открытия приложения подряд без действий, 1 189 человек. Эту группу стоит изучить отдельно: люди возвращаются, но ничего не делают.

Результат на учебной базе
first_threeusers
app_open → workspace_created → report_created1 775
app_open → app_open → app_open1 189
app_open → workspace_created → app_open796
app_open → app_open → invite_sent235
app_open → invite_sent → app_open198
Самые частые первые три события пользователя
with firsts as (
  select
    user_id,
    (array_agg(event_name order by event_time, event_id))[1:3] as first_events
  from events
  group by user_id
)
select
  array_to_string(first_events, ' → ') as first_three,
  count(*)                             as users
from firsts
group by first_events
order by users desc, first_three
limit 5;

Топ-N в каждой группе одной строкой

Классическая задача «три первых в каждой группе» обычно решается оконной функцией row_number. Если ответ нужен компактно — одной строкой на группу, — хватает array_agg со срезом. Запрос ниже показывает в каждом канале трёх пользователей с самой дорогой подпиской; при одинаковой цене побеждает меньший id.

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

Три пользователя с самой дорогой подпиской в каждом канале
select
  u.channel,
  array_to_string(
    (array_agg(u.user_id order by s.monthly_price desc, u.user_id))[1:3],
    ', '
  ) as top3_users
from users u
join subscriptions s on s.user_id = u.user_id
group by u.channel
order by u.channel;

Проверки: ANY, @> и &&

Когда у каждого пользователя есть набор событий, удобно собрать его в массив без повторов (array_agg(distinct …)) и проверять условия на наборе. Три оператора закрывают почти все вопросы: есть ли конкретное событие, есть ли все из списка, есть ли хотя бы одно из списка.

В учебной базе из 4 613 пользователей отчёт создали 1 775, отчёт и выгрузку — 657, а пригласили коллегу или выгрузили отчёт — 1 877. Такой запрос — быстрый способ посчитать пересечения действий без цепочки соединений.

Результат на учебной базе
userscreated_reportcreated_and_exportedinvited_or_exported
4 6131 7756571 877
Наборы событий пользователя: одно, все из списка, хотя бы одно
with sets as (
  select
    user_id,
    array_agg(distinct event_name) as names
  from events
  group by user_id
)
select
  count(*)                                                                    as users,
  count(*) filter (where 'report_created' = any(names))                       as created_report,
  count(*) filter (where names @> array['report_created', 'export_completed']) as created_and_exported,
  count(*) filter (where names && array['invite_sent', 'export_completed'])    as invited_or_exported
from sets;

UNNEST: развернуть массив в строки

unnest делает обратное: превращает каждый элемент массива в отдельную строку. Типичный случай — таблица, где теги или категории хранятся массивом, а нужно посчитать, сколько раз встречается каждый тег. Пишется через cross join unnest(столбец): каждая строка таблицы соединяется со своими элементами.

Учтите: строка с пустым массивом или NULL при cross join пропадает целиком. Если такие строки тоже нужны в результате, используйте left join unnest(tags) as u(tag) on true.

Сколько статей с каждым тегом: учебные строки собраны в запросе
select tag, count(*) as articles
from (
  values
    (1, array['sql', 'join']),
    (2, array['sql', 'window', 'lag']),
    (3, array['excel'])
) as t(article_id, tags)
cross join unnest(tags) as u(tag)
group by tag
order by articles desc, tag;

Номер элемента: WITH ORDINALITY

Иногда важна не только сама величина, но и её место в списке: каким по счёту был платёж. unnest(…) with ordinality добавляет к каждому элементу его номер. Запрос ниже разворачивает пути тарифов обратно и считает, сколько пользователей на каждом шаге платили за каждый тариф.

Тарифы по номеру платежа у пользователей с двумя и более платежами
with paths as (
  select
    user_id,
    array_agg(plan order by paid_at, payment_id) as plans
  from payments
  group by user_id
  having count(*) >= 2
)
select
  step.n     as payment_number,
  step.plan,
  count(*)   as users
from paths
cross join unnest(plans) with ordinality as step(plan, n)
group by step.n, step.plan
order by step.n, users desc;

Строка с разделителями: string_to_array

Выгрузки из CRM и форм часто хранят список в одной текстовой ячейке: «sql,join,lag». string_to_array(s, ',') превращает такую строку в массив, а дальше работают все приёмы выше: unnest для подсчёта, any для фильтра. Обратную операцию делает array_to_string.

Перед разбором проверьте пробелы: строка «sql, join» с пробелом после запятой даст элемент « join» с ведущим пробелом, и он не совпадёт с «join». Уберите пробелы через trim после unnest или разделяйте по «, ».

Строка с разделителями — в массив и обратно
select
  array_to_string(string_to_array('sql,join,lag', ','), ' / ') as tags;

Ловушки с NULL и пустыми массивами

Что возвращает PostgreSQL в пограничных случаях
ВыражениеРезультатПочему важно
array_length('{}'::int[], 1)NULL, а не 0условие array_length(arr, 1) = 0 не найдёт пустые массивы
cardinality('{}'::int[])0для пустых массивов надёжнее; есть только в PostgreSQL
array_agg(x) по группе без строкNULLпосле left join проверяйте coalesce(…, '{}')
3 = any(array[1, null])NULL, а не falsenot (x = any(arr)) теряет строки, если в массиве есть NULL
arr[10] при длине 3NULLошибки нет, промах по индексу проходит молча

Хранить ли массивы в таблице

Массив в столбце удобен для коротких списков, которые читают целиком и редко меняют поэлементно: теги, роли, список выбранных опций. Поиск по такому столбцу через @> и && ускоряет индекс GIN: create index on articles using gin (tags).

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

Массивы в других СУБД

Идея есть почти везде, отличаются названия. В DuckDB массивы называются списками, но основные функции из статьи — array_agg, unnest, срезы, any, @>, &&, array_to_string — работают так же, поэтому запросы выполняются в песочнице без изменений.

Собрать и развернуть массив в разных СУБД
СУБДСобратьРазвернутьЕсть ли значение
PostgreSQL, DuckDBarray_agg(x)unnest(arr)x = any(arr)
ClickHousegroupArray(x)arrayJoin(arr)has(arr, x)
BigQueryarray_agg(x)unnest(arr)x in unnest(arr)
MySQLjson_arrayagg(x)json_table(…)json_contains(…)

Частые вопросы

Чем array_agg отличается от string_agg?

array_agg возвращает массив, с которым можно работать дальше: брать элементы, срезы, проверять вхождение. string_agg сразу склеивает текст — удобно для вывода, но дальше это просто строка.

Как посчитать длину массива?

array_length(arr, 1) — для одномерного массива; для пустого вернёт NULL. В PostgreSQL есть cardinality(arr), которая для пустого массива вернёт 0.

Как проверить, что значение есть в массиве?

'pro' = any(plans). Чтобы проверить сразу несколько значений, используйте @>.

С какого номера начинаются индексы массива?

С единицы. arr[0] вернёт NULL.

Как развернуть массив и сохранить порядок?

unnest(arr) with ordinality добавит номер элемента.

Группировки, срезы и проверки из статьи можно выполнить в песочнице на учебной базе, а сложную агрегацию — отработать в курсе «Продвинутый SQL».

Продолжить чтение
Вся библиотека