Table partitioning

Split large time-series or tenant tables into partitions in a SupaHost project with Postgres declarative partitioning, keep RLS safe, create partitions with pg_cron and drop old data cheaply.

A partitioned table looks like one table to your app but is stored as several smaller tables, called partitions, each holding a slice of the rows: one month of events, one region, one group of tenants. Queries that filter on the partition key only read the partitions they need, and removing a month of old data becomes a quick drop instead of a slow delete.

Partitioning adds moving parts, so use it when a table is genuinely large (tens of millions of rows or many gigabytes) and you query or expire it by one column, usually time. For smaller tables, a good index does the job. See Indexes.

Partitioning strategies

Strategy Rows go to a partition by Typical use
Range A range of values, such as one month of created_at Logs, events, messages, metrics
List Explicit values, such as region in ('eu', 'uk') Data split by region or category
Hash A hash of the key, spread evenly over N partitions Spreading a large tenant table evenly

Create a range-partitioned table

A team chat app keeps every message, and most reads are for recent ones. Partition by month:

create table public.messages (
  id bigint generated always as identity,
  channel_id bigint not null,
  user_id uuid not null default auth.uid(),
  body text not null,
  created_at timestamptz not null default now(),
  primary key (id, created_at)
) partition by range (created_at);

The primary key must include the partition key (created_at), because Postgres can only enforce uniqueness within each partition. The same applies to any unique constraint.

Keep partitions out of the Data API

Each partition is a real table. If you create partitions in public, the Data API exposes them as their own endpoints, and RLS policies on the parent table don't apply when someone queries a partition directly. Put partitions in a schema the Data API doesn't serve:

create schema if not exists partitions;
revoke all on schema partitions from anon, authenticated;

create table partitions.messages_2026_11 partition of public.messages
  for values from ('2026-11-01') to ('2026-12-01');

create table partitions.messages_2026_12 partition of public.messages
  for values from ('2026-12-01') to ('2027-01-01');

create table partitions.messages_default partition of public.messages default;

The default partition catches rows that don't fit any range, so an insert never fails because next month's partition is missing. Keep it empty in normal operation; if rows pile up there, a partition is missing.

The Data API only serves public, storage, graphql_public and pgmq_public on SupaHost, and that list can't be changed, so a partitions schema stays private.

Add RLS and indexes on the parent

Enable RLS and create policies on the parent table. Queries through public.messages apply them to every partition:

alter table public.messages enable row level security;

create policy "Members read their channels"
  on public.messages for select
  to authenticated
  using (
    exists (
      select 1 from public.channel_members m
      where m.channel_id = messages.channel_id
        and m.user_id = (select auth.uid())
    )
  );

create policy "Members post as themselves"
  on public.messages for insert
  to authenticated
  with check ((select auth.uid()) = user_id);

An index created on the parent is created on every existing and future partition automatically:

create index messages_channel_created_idx on public.messages (channel_id, created_at desc);

From the app nothing changes:

const { data } = await supabase
  .from('messages')
  .select('id, body, created_at')
  .eq('channel_id', 42)
  .gte('created_at', new Date(Date.now() - 7 * 864e5).toISOString())
  .order('created_at', { ascending: false })
  .limit(50)

The created_at filter lets Postgres skip partitions outside the last week (partition pruning). Check it with explain: only the relevant partitions should appear in the plan.

Create partitions ahead of time with pg_cron

Rather than remembering to add a partition each month, schedule a job. pg_cron is built into every SupaHost project; don't run create extension pg_cron.

create or replace function public.create_next_message_partition()
returns void
language plpgsql
set search_path = ''
as $$
declare
  start_month date := date_trunc('month', now() + interval '1 month');
  end_month   date := start_month + interval '1 month';
  part_name   text := 'messages_' || to_char(start_month, 'YYYY_MM');
begin
  execute format(
    'create table if not exists partitions.%I partition of public.messages
       for values from (%L) to (%L)',
    part_name, start_month, end_month
  );
end;
$$;

revoke execute on function public.create_next_message_partition() from public, anon, authenticated;

select cron.schedule('create-message-partition', '0 3 20 * *',
  $$select public.create_next_message_partition()$$);

This runs at 03:00 UTC on the 20th of each month and creates next month's partition. Jobs show up in Dashboard → your project → Cron tab, where you can check recent runs. See Scheduled jobs with pg_cron.

Remove old data

Detach a partition to take it out of the table without deleting anything. Detaching is quick but briefly locks the parent table, so set a lock timeout to give up rather than queue behind busy queries:

set lock_timeout = '5s';
alter table public.messages detach partition partitions.messages_2025_01;

Postgres also has detach partition ... concurrently, which avoids that lock, but it isn't allowed while the table has a default partition, and it can't run inside a transaction.

The detached table still holds its rows. Export or archive it first if you may need it, for example with a pg_dump from the Backups tab (Exporting your data), then drop it:

drop table partitions.messages_2025_01;

Dropping a partition frees its disk space straight away, unlike delete, which leaves dead rows for vacuum to clean up. See Database size not shrinking after deleting data.

Things to know

  • Realtime Postgres Changes: test subscriptions on partitioned tables before relying on them; replication of partitioned tables has extra settings and isn't covered here. For chat-style apps, Broadcast is often a better fit: see Broadcast.
  • Foreign keys pointing at a partitioned table must reference the full primary key, including the partition key.
  • Converting an existing table needs a new partitioned table and a data copy. See Partitioning an existing table.
  • Branches copy partitions like any other table. See Branching.

Updated 11 November 2026 · View as Markdown · Something wrong or missing? Tell us