---
title: "Come partizionare una tabella PostgreSQL da 1 TB senza downtime: il metodo della default partition"
description: "Partiziona una tabella Postgres da 1 TB senza downtime: agganciala come DEFAULT partition, copia lo storico con job Oban e fai lo swap in una transazione breve."
author: Federico Meini
date: 2026-08-18
tags: [postgresql, partitioning, performance, elixir, oban]
language: it
url: https://fedme.dev/it/blog/partitioning-a-1tb-postgres-table-without-downtime
---

# Come partizionare una tabella PostgreSQL da 1 TB senza downtime: il metodo della default partition

Per partizionare senza downtime una tabella PostgreSQL enorme e in produzione, rendila la DEFAULT partition di una nuova tabella padre con una transazione breve che tocca solo il catalogo, manda subito le nuove scritture in partition temporali vere e sposta lo storico fuori dalla default in background. In Turn.io ho partizionato così tabelle di produzione da oltre 1 TB per recuperare performance, mentre i dati dei messaggi crescevano di milioni di righe al giorno: la vecchia tabella è diventata la default partition e job Oban ne hanno spostato gradualmente i dati in partition dedicate.

## Cosa ho fatto in Turn.io

La migrazione che ho fatto in Turn.io era molto simile all’approccio di questo articolo:

- **La vecchia tabella è diventata la default partition,** e i dati nuovi finivano subito nelle partizioni per intervallo di tempo.
- **Niente pg_partman.** La creazione anticipata delle partition e lo spostamento dello storico erano semplici job Oban nel nostro codice Elixir.
- **Job Oban copiavano lo storico in nuove tabelle in background,** e ogni tabella completata veniva agganciata come partition con un’unica operazione atomica, così chi leggeva non vedeva mai buchi né duplicati.
- **I CHECK constraint hanno fatto gran parte del lavoro.** Ne ho aggiunti molti `NOT VALID` e li ho validati dopo con `VALIDATE CONSTRAINT`, che prende solo un lock `SHARE UPDATE EXCLUSIVE`. Così Postgres poteva fidarsi degli intervalli in fase di attach, invece di fare uno scan di terabyte con un lock bloccante.
- **Le foreign key sono state rimosse per la durata dello spostamento.** Il partitioning obbliga le foreign key a includere la chiave di partizione, e intralciano attach e detach. Avevamo già controlli a livello applicativo e trigger che garantivano le stesse relazioni, quindi toglierle per la migrazione era un compromesso sicuro.

Ecco come lo farei oggi, ricostruito su PostgreSQL 18.6 con una tabella `messages` da 4 milioni di righe sotto carico pgbench costante; i tempi vengono da quel test sul mio portatile. Per chiavi, pruning, index e retention, leggi [Il partitioning di PostgreSQL in pratica](https://fedme.dev/it/blog/postgresql-partitioning-in-practice).

## In breve

- **Preparazione online:** unique index su `(id, inserted_at)`, index allineati, nessuna foreign key in entrata e un `CHECK (inserted_at < limite)` validato.
- **Cutover:** una transazione di pochi millisecondi aggancia la vecchia tabella `AS DEFAULT`.
- **Backfill:** spostare un intervallo alla volta fuori da una default grande la scansiona tutta sotto `ACCESS EXCLUSIVE`. Meglio copiare lo storico in partition non agganciate con Oban, allinearle con un trigger e fare un solo swap (circa 50 ms di lock).

## Passo 1: preparare la vecchia tabella mentre è in produzione

**La chiave di partizione deve stare in ogni primary key e vincolo unique**, quindi costruisci prima l’index della futura primary key:

```elixir
defmodule MyApp.Repo.Migrations.AddMessagesIdInsertedAtIndex do
  use Ecto.Migration

  @disable_ddl_transaction true
  @disable_migration_lock true

  def change do
    create unique_index(:messages, [:id, :inserted_at], concurrently: true)
  end
end
```

**Allinea gli index.** Durante l’attach, Postgres adotta un index equivalente per ogni index del padre e costruisce sotto lock quelli mancanti: devono quindi esistere già tutti sulla vecchia tabella.

**Aggiungi il CHECK sul limite**, a un inizio mese tra pochi giorni. Senza, ogni partition creata finché la vecchia tabella è la default la scansiona tutta. `NOT VALID` rende l’aggiunta istantanea; la validazione prende uno `SHARE UPDATE EXCLUSIVE`, che non blocca letture né scritture (617 ms su 700 MB):

```elixir
defmodule MyApp.Repo.Migrations.AddMessagesLegacyRangeCheck do
  use Ecto.Migration

  def change do
    create constraint(:messages, :messages_legacy_before_2026_10,
             check: "inserted_at < '2026-10-01 00:00:00+00'",
             validate: false
           )
  end
end
```

Poi, in un’altra migration: `execute "ALTER TABLE messages VALIDATE CONSTRAINT messages_legacy_before_2026_10"`.

**Foreign key in entrata.** In Turn.io abbiamo rimosso le foreign key per la durata della migrazione, perché controlli a livello applicativo e trigger garantivano già le stesse relazioni. Se non puoi permetterti questo compromesso, pianifica con attenzione. Postgres aggancia anche una tabella referenziata, ma le foreign key restano puntate a quell’unica partition e impediscono di eliminare la vecchia primary key. Eliminale e gestisci la relazione nel codice, o aggiungi il timestamp alla tabella referenziante per una foreign key composta verso il nuovo padre (PostgreSQL 12+).

**Sequence e identity.** `LIKE ... INCLUDING DEFAULTS` copia il default `nextval('messages_id_seq')` di Ecto; sposta la proprietà della sequence, o eliminando la vecchia tabella la perdi. PostgreSQL 18 non aggancia tabelle con una propria identity: toglila alla vecchia tabella nel cutover e fai `RESTART` di quella del padre sopra `max(id)`, come nella [guida di pg_partman](https://github.com/pgpartman/pg_partman/blob/master/doc/pg_partman_howto.md).

## Passo 2: la transazione di cutover

```sql
BEGIN;
SET LOCAL lock_timeout = '2s';
LOCK TABLE messages IN ACCESS EXCLUSIVE MODE;

ALTER TABLE messages RENAME TO messages_legacy;
ALTER TABLE messages_legacy
  DROP CONSTRAINT messages_pkey,
  ADD CONSTRAINT messages_legacy_pkey PRIMARY KEY USING INDEX messages_id_inserted_at_index;
ALTER INDEX messages_contact_id_inserted_at_index RENAME TO messages_legacy_contact_id_inserted_at_index;
ALTER INDEX messages_inserted_at_index RENAME TO messages_legacy_inserted_at_index;
ALTER TABLE messages_legacy RENAME CONSTRAINT messages_contact_id_fkey TO messages_legacy_contact_id_fkey;

CREATE TABLE messages (LIKE messages_legacy INCLUDING DEFAULTS) PARTITION BY RANGE (inserted_at);
ALTER TABLE messages ADD CONSTRAINT messages_pkey PRIMARY KEY (id, inserted_at);
CREATE INDEX messages_contact_id_inserted_at_index ON messages (contact_id, inserted_at);
CREATE INDEX messages_inserted_at_index ON messages (inserted_at);
ALTER TABLE messages ADD CONSTRAINT messages_contact_id_fkey
  FOREIGN KEY (contact_id) REFERENCES contacts (id);

ALTER TABLE messages ATTACH PARTITION messages_legacy DEFAULT;
ALTER SEQUENCE messages_id_seq OWNED BY messages.id;

CREATE TABLE messages_p2026_10 PARTITION OF messages
  FOR VALUES FROM ('2026-10-01 00:00:00+00') TO ('2026-11-01 00:00:00+00');
COMMIT;
```

Niente `INCLUDING CONSTRAINTS`, o il CHECK finisce sul padre. (Uso `timestamptz`; con le colonne `timestamp` predefinite di Ecto, togli il `+00` dai limiti.) Lo scambio di primary key è obbligatorio, o l’attach fallisce con “multiple primary keys for table "messages_legacy" are not allowed”.

**Perché agganciare come DEFAULT costa poco.** La default prende ciò che nessun’altra partition accetta: senza altre partition non c’è niente da validare (1,1 ms). Creare `messages_p2026_10` scansionerebbe la default, ma il CHECK esclude ottobre; con `client_min_messages = debug1` Postgres scrive “updated partition constraint for default partition "messages_legacy" is implied by existing constraints”.

**Lock.** Fino al `COMMIT`: `ACCESS EXCLUSIVE` su vecchia tabella, nuovo padre e partition e, con mia sorpresa, su `contacts`: in PostgreSQL 18, agganciare una tabella con una propria foreign key blocca anche la tabella referenziata. La migration Ecto ha impiegato 70-140 ms, senza errori per i client pgbench con prepared statement, come Postgrex. Tieni il `lock_timeout`: dietro una query lunga il tuo `LOCK TABLE` aspetta, e le altre query si accodano dietro di te.

## Passo 3: mandare le nuove scritture nelle partition vere

Dal limite in poi, le righe vanno in partition vere. Creane alcune in anticipo, mai per un intervallo ancora nella default:

```elixir
defmodule MyApp.Workers.CreateMessagePartitions do
  use Oban.Worker, queue: :maintenance, max_attempts: 5

  alias MyApp.Repo

  @impl Oban.Worker
  def perform(%Oban.Job{}) do
    # Dal mese prossimo: il corrente esiste già.
    next_month(Date.utc_today())
    |> Stream.iterate(&next_month/1)
    |> Enum.take(3)
    |> Enum.each(&ensure_partition/1)
  end

  defp ensure_partition(from) do
    name = "messages_p" <> Calendar.strftime(from, "%Y_%m")

    {:ok, _} =
      Repo.transaction(fn ->
        Repo.query!("SET LOCAL lock_timeout = '2s'")

        if Repo.query!("SELECT to_regclass($1)", [name]).rows == [[nil]] do
          Repo.query!("CREATE TABLE #{name} (LIKE messages INCLUDING DEFAULTS)")

          Repo.query!("""
          ALTER TABLE messages ATTACH PARTITION #{name}
            FOR VALUES FROM ('#{from} 00:00:00+00') TO ('#{next_month(from)} 00:00:00+00')
          """)
        end
      end)
  end

  defp next_month(date), do: date |> Date.end_of_month() |> Date.add(1)
end
```

Schedulalo ogni giorno con `Oban.Plugins.Cron`, o usa `run_maintenance()` di pg_partman. `ATTACH` richiede solo `SHARE UPDATE EXCLUSIVE` sul padre, a differenza di `CREATE TABLE ... PARTITION OF` ([documentazione](https://www.postgresql.org/docs/current/sql-createtable.html)), ma blocca in modo esclusivo la default, con o senza scansione. Con il CHECK, una nuova partition ha richiesto 2 ms; senza, 700 ms, e cresce con la default.

## Passo 4: spostare lo storico fuori dalla default

### Cosa fa pg_partman

La [guida al partitioning online di pg_partman](https://github.com/pgpartman/pg_partman/blob/master/doc/pg_partman_howto.md) usa lo stesso setup, poi `partition_data_proc()`. Ogni batch è una transazione: `DELETE ... RETURNING` di un intero intervallo dalla default a una tabella temporanea, creazione e `ATTACH` della partition, reinserimento delle righe. I batch non possono essere più piccoli dell’intervallo di partizione (“Custom intervals are not allowed when moving data out of the DEFAULT partition”), perché la partition non può esistere finché le sue righe sono nella default. Fa commit a ogni batch, può fare pause (`p_wait`) e bloccare prima le righe (`p_lock_wait`).

Con pg_partman 5.5.0, spostare un mese (248mila righe) ha richiesto 7 secondi, con le letture sulla default ferme per circa 2,5: l’attach blocca la default, la scansiona tutta e tiene il lock fino a fine insert. Con update concorrenti su righe vecchie, il primo tentativo è finito in deadlock. [Crunchy Data](https://www.crunchydata.com/blog/postgres-partitioning-with-a-default-partition) e la documentazione di pg_partman consigliano di tenere piccola la default. Su un terabyte, quella scansione si ripete ogni mese.

### Perché stringere un CHECK non basta

L’idea allettante: copiare un mese in una tabella staccata, poi in una transazione breve cancellarlo dalla default e agganciare la copia, dal più vecchio, con un CHECK sulla default che esclude l’intervallo. Ma l’attach vuole una prova: una scansione o un CHECK *validato* (`NOT VALID` non conta, l’ho verificato), validabile solo a righe già sparite. Nella stessa transazione è una scansione completa sotto lock; in una separata, le righe restano per un attimo invisibili.

### Copiare, catturare le modifiche, un solo swap

Non chiedere a Postgres di dimostrare nulla sull’heap enorme. I job Oban copiano lo storico in partition non agganciate, un trigger le allinea e una transazione stacca la vecchia default e aggancia tutto. Il CHECK di ogni partition ne evita la validazione, e senza default non c’è scansione della default. È l’idea di trigger e copia in background degli [helper di partitioning di GitLab](https://docs.gitlab.com/development/database/partitioning/date_range/), applicata solo allo storico.

```sql
CREATE TABLE messages_backfill (LIKE messages INCLUDING DEFAULTS) PARTITION BY RANGE (inserted_at);
ALTER TABLE messages_backfill ADD PRIMARY KEY (id, inserted_at);

-- Una per mese; la più vecchia usa FROM (MINVALUE) e CHECK (inserted_at < ...).
CREATE TABLE messages_p2026_03 (LIKE messages INCLUDING DEFAULTS);
ALTER TABLE messages_p2026_03 ADD CONSTRAINT messages_p2026_03_bounds
  CHECK (inserted_at >= '2026-03-01 00:00:00+00' AND inserted_at < '2026-04-01 00:00:00+00');
ALTER TABLE messages_backfill ATTACH PARTITION messages_p2026_03
  FOR VALUES FROM ('2026-03-01 00:00:00+00') TO ('2026-04-01 00:00:00+00');

CREATE TABLE messages_backfill_changes (
  seq bigserial PRIMARY KEY, id bigint NOT NULL, inserted_at timestamptz NOT NULL
);

CREATE FUNCTION messages_backfill_capture() RETURNS trigger LANGUAGE plpgsql AS $$
BEGIN
  IF TG_OP IN ('UPDATE', 'DELETE') THEN
    INSERT INTO messages_backfill_changes (id, inserted_at) VALUES (OLD.id, OLD.inserted_at);
  END IF;
  IF TG_OP IN ('INSERT', 'UPDATE') THEN
    INSERT INTO messages_backfill_changes (id, inserted_at) VALUES (NEW.id, NEW.inserted_at);
  END IF;
  RETURN NULL;
END $$;

CREATE TRIGGER messages_backfill_capture
  AFTER INSERT OR UPDATE OR DELETE ON messages_legacy
  FOR EACH ROW EXECUTE FUNCTION messages_backfill_capture();

CREATE TABLE messages_backfill_progress (
  id int PRIMARY KEY DEFAULT 1 CHECK (id = 1),
  last_id bigint NOT NULL DEFAULT 0,
  rows_copied bigint NOT NULL DEFAULT 0,
  changes_applied bigint NOT NULL DEFAULT 0,
  target_max_id bigint
);
INSERT INTO messages_backfill_progress (target_max_id) SELECT max(id) FROM messages_legacy;
```

Crea il trigger prima di copiare. Poi servono due funzioni:

```sql
CREATE FUNCTION messages_backfill_copy_batch(batch_size int) RETURNS int
LANGUAGE sql AS $$
  WITH batch AS (
    SELECT id, contact_id, direction, status, body, inserted_at, updated_at
    FROM messages_legacy
    WHERE id > (SELECT last_id FROM messages_backfill_progress)
    ORDER BY id
    LIMIT batch_size
  ), copied AS (
    INSERT INTO messages_backfill (id, contact_id, direction, status, body, inserted_at, updated_at)
    SELECT * FROM batch
    ON CONFLICT (id, inserted_at) DO NOTHING
  )
  UPDATE messages_backfill_progress
  SET last_id = coalesce((SELECT max(id) FROM batch), last_id),
      rows_copied = rows_copied + (SELECT count(*) FROM batch)
  RETURNING (SELECT count(*) FROM batch)::int;
$$;

CREATE FUNCTION messages_backfill_apply_changes(batch_size int) RETURNS int
LANGUAGE sql
-- Il planner non stima le CTE: evita hash join su scansioni complete.
SET enable_hashjoin = off
SET enable_mergejoin = off
AS $$
  WITH claimed AS (
    DELETE FROM messages_backfill_changes
    WHERE seq IN (SELECT seq FROM messages_backfill_changes ORDER BY seq LIMIT batch_size)
    RETURNING id, inserted_at
  ), keys AS (
    SELECT DISTINCT id, inserted_at FROM claimed
  ), removed AS (
    DELETE FROM messages_backfill b USING keys k
    WHERE b.id = k.id AND b.inserted_at = k.inserted_at
      AND NOT EXISTS (SELECT 1 FROM messages_legacy m
                      WHERE m.id = k.id AND m.inserted_at = k.inserted_at)
  ), upserted AS (
    INSERT INTO messages_backfill (id, contact_id, direction, status, body, inserted_at, updated_at)
    SELECT m.id, m.contact_id, m.direction, m.status, m.body, m.inserted_at, m.updated_at
    FROM messages_legacy m JOIN keys k ON m.id = k.id AND m.inserted_at = k.inserted_at
    ON CONFLICT (id, inserted_at) DO UPDATE
    SET contact_id = EXCLUDED.contact_id, direction = EXCLUDED.direction,
        status = EXCLUDED.status, body = EXCLUDED.body, updated_at = EXCLUDED.updated_at
  )
  UPDATE messages_backfill_progress
  SET changes_applied = changes_applied + (SELECT count(*) FROM claimed)
  RETURNING (SELECT count(*) FROM claimed)::int;
$$;
```

Sono idempotenti: la copia avanza il cursore nella stessa transazione e salta le righe presenti; il replay ricopia ogni riga modificata com’è *adesso*, o la rimuove. Senza le righe `SET`, riapplicare 10.000 modifiche richiedeva da 2 a 4 secondi (il piano scansionava ogni partition di staging); con quelle, 0,3 secondi, qualunque sia la dimensione della tabella.

Il worker esegue un batch alla volta e fa snooze finché lo swap non elimina le sue tabelle:

```elixir
defmodule MyApp.Workers.MessagesBackfill do
  use Oban.Worker,
    queue: :partition_backfill,
    max_attempts: 20,
    unique: [period: :infinity, states: :incomplete]

  require Logger
  alias MyApp.Repo

  # Condivisa con lo swap. Una costante qualsiasi che nient'altro usa.
  @lock_key 820_417

  @impl Oban.Worker
  def perform(%Oban.Job{}) do
    if staging_exists?() do
      config = Application.get_env(:my_app, __MODULE__, [])
      batch_size = Keyword.get(config, :batch_size, 5_000)

      case Repo.transaction(fn -> run_batch(batch_size) end, timeout: :timer.seconds(90)) do
        {:ok, :busy} -> {:snooze, 10}
        # Copia finita: tieni basso l'arretrato fino allo swap.
        {:ok, {0, 0}} -> {:snooze, 5}
        {:ok, {copied, applied}} -> report_progress(copied, applied)
      end
    else
      :ok
    end
  end

  defp run_batch(batch_size) do
    %{rows: [[locked?]]} = Repo.query!("SELECT pg_try_advisory_xact_lock($1)", [@lock_key])

    if locked? do
      Repo.query!("SET LOCAL lock_timeout = '2s'")
      Repo.query!("SET LOCAL statement_timeout = '60s'")
      %{rows: [[applied]]} = Repo.query!("SELECT messages_backfill_apply_changes($1)", [batch_size])
      %{rows: [[copied]]} = Repo.query!("SELECT messages_backfill_copy_batch($1)", [batch_size])
      {copied, applied}
    else
      :busy
    end
  end

  defp staging_exists? do
    Repo.query!("SELECT to_regclass('messages_backfill_changes') IS NOT NULL").rows == [[true]]
  end

  defp report_progress(copied, applied) do
    %{rows: [[last_id, target]]} =
      Repo.query!("SELECT last_id, target_max_id FROM messages_backfill_progress")

    Logger.info("messages backfill: id #{last_id}/#{target}, +#{copied}, #{applied} replayed")
    {:snooze, Keyword.get(Application.get_env(:my_app, __MODULE__, []), :pause_seconds, 1)}
  end
end
```

`unique` con `states: :incomplete` rende un secondo insert un no-op. In Oban open source i limiti delle code valgono per nodo: è l’advisory lock a impedire che i batch si sovrappongano tra nodi. `{:snooze, n}` riesegue lo stesso job e in Oban 2.24 lo snooze non consuma tentativi. Batch e pausa stanno nella config, per rallentare se le repliche restano indietro.

Finita la copia, dai a ogni partition di staging gli altri index del padre con `CREATE INDEX CONCURRENTLY` (in una migration con i due flag visti sopra, perché il worker ci scrive ancora), più la foreign key `NOT VALID`, poi validala e fai `ANALYZE`. Ciò che manca verrebbe costruito sotto il lock dello swap.

### Lo swap

```sql
BEGIN;
SET LOCAL lock_timeout = '2s';
DO $$ BEGIN
  IF (SELECT count(*) FROM messages_backfill_changes) > 20000 THEN
    RAISE EXCEPTION 'change backlog too large, let the backfill worker catch up';
  END IF;
END $$;
SELECT pg_advisory_xact_lock(820417);

-- Recupera l'arretrato mentre tutti possono ancora leggere e scrivere.
DO $$ BEGIN
  WHILE messages_backfill_apply_changes(10000) > 500 LOOP END LOOP;
END $$;

LOCK TABLE ONLY messages IN ACCESS EXCLUSIVE MODE;
LOCK TABLE messages_legacy IN ACCESS EXCLUSIVE MODE;

-- Riapplica le ultime modifiche, ora che nessuno può scrivere.
DO $$ BEGIN
  WHILE messages_backfill_apply_changes(10000) > 0 LOOP END LOOP;
END $$;

ALTER TABLE messages DETACH PARTITION messages_legacy;

DO $$
DECLARE parts record;
BEGIN
  FOR parts IN
    SELECT c.relname AS name, pg_get_expr(c.relpartbound, c.oid) AS bound
    FROM pg_inherits i JOIN pg_class c ON c.oid = i.inhrelid
    WHERE i.inhparent = 'messages_backfill'::regclass
  LOOP
    EXECUTE format('ALTER TABLE messages_backfill DETACH PARTITION %I', parts.name);
    EXECUTE format('ALTER TABLE messages ATTACH PARTITION %I %s', parts.name, parts.bound);
  END LOOP;
END $$;

DROP TRIGGER messages_backfill_capture ON messages_legacy;
DROP TABLE messages_backfill, messages_backfill_changes, messages_backfill_progress;
DROP FUNCTION messages_backfill_copy_batch, messages_backfill_apply_changes, messages_backfill_capture;
COMMIT;
```

Misure sotto carico:

- `count(*)` attraverso il padre è rimasto identico in tutti i 143 campioni presi durante copia, index e swap, senza transazioni fallite.
- Con 246.000 update, delete e insert retrodatati concorrenti durante la copia, `EXCEPT` nei due sensi tra tabella staccata e nuove partition è risultato vuoto.
- Un primo tentativo ha bloccato tutto per 4,6 secondi: 16.000 modifiche accumulate, riapplicate sotto lock. Da qui il controllo sull’arretrato e il recupero preliminare.

### Compromessi

- **Lock:** forti solo nello swap, e per poco.
- **WAL e replica:** si riscrive tutto lo storico, heap e index; limita il ritmo e guarda il lag delle repliche.
- **Disco:** serve spazio per una seconda copia fino al drop della vecchia tabella.
- **Bloat e VACUUM:** dalla default non si cancella nulla, quindi niente VACUUM; se ne va con un `DROP TABLE`.
- **Righe modificate durante lo spostamento:** ci pensano trigger e replay, con un piccolo costo sulle scritture di righe vecchie.
- **Tutto in una volta:** lo storico va in produzione con un solo swap. Tieni la tabella staccata finché non hai verificato i dati.

pg_partman va bene se la default è piccola o se puoi permetterti il lock in un momento tranquillo.

## Passo 5: pulire e tenere vuota la default

Verifica i dati sulla tabella staccata, poi eliminala; la sequence resta, perché appartiene a `messages.id`. Elimina i CHECK `_bounds`, come [consiglia la documentazione](https://www.postgresql.org/docs/current/ddl-partitioning.html), e lancia tu `ANALYZE messages`: l’autovacuum non analizza mai un padre partizionato.

Dopo, preferisco non avere una default: le righe senza partition falliscono in modo visibile e `DETACH PARTITION ... CONCURRENTLY` funziona (con una default si rifiuta). Per non rifiutare mai un insert, tieni una piccola `messages_default` con un alert se contiene righe. E in ogni caso, un alert se restano meno di due partition future:

```sql
SELECT EXISTS (SELECT 1 FROM messages_default) AS rows_in_default;

SELECT count(*) AS future_partitions
FROM pg_inherits i JOIN pg_class c ON c.oid = i.inhrelid
WHERE i.inhparent = 'messages'::regclass
  AND c.relname > 'messages_p' || to_char(now() AT TIME ZONE 'UTC', 'YYYY_MM');
```

## L’alternativa: agganciare la vecchia tabella come un’unica range partition

Se lo storico va solo conservato finché la retention non lo elimina, salta il backfill: con lo stesso CHECK validato e `inserted_at NOT NULL`, aggancia la vecchia tabella come un’unica range partition (2 ms, nessuna scansione):

```sql
ALTER TABLE messages ATTACH PARTITION messages_legacy
  FOR VALUES FROM (MINVALUE) TO ('2026-10-01 00:00:00+00');
```

Niente copia, WAL o trigger, ma una partition enorme, con index e VACUUM pesanti come oggi finché non la elimini. La sceglierei quando i dati vecchi si interrogano di rado; il backfill, quando anche le query sullo storico devono accelerare.

## Se hai davanti questa migrazione

Prova tutto su una copia di produzione. Se il tuo cluster Postgres (o Elasticsearch) rallenta con la crescita dei dati e vuoi qualcuno che l’abbia già fatto in produzione per pianificarlo ed eseguirlo con il tuo team, [aiuto i team proprio in questo](https://fedme.dev/it/services/postgres-elasticsearch).
