35+ Years Experience Netherlands Based ⚡ Fast Response Times Ruby on Rails Experts AI-Powered Development Fixed Pricing Available Senior Architects Dutch & English 35+ Years Experience Netherlands Based ⚡ Fast Response Times Ruby on Rails Experts AI-Powered Development Fixed Pricing Available Senior Architects Dutch & English
Rails Postgres Table Partitioning: Tijd-Gebaseerde Partities voor Grote Tabellen in Productie

Rails Postgres Table Partitioning: Tijd-Gebaseerde Partities voor Grote Tabellen in Productie

Roger Heykoop
Ruby on Rails, Postgres
Rails Postgres table partitioning voor enorme tabellen: tijd-gebaseerde partities, pg_partman, indexen, valkuilen en een no-downtime migratie playbook.

Een fintech klant belde me op een zondag omdat hun events tabel de vier miljard rijen voorbij was geschoten en het dashboard niet meer laadde. Elke query die de tabel raakte scande weken aan koude historie alleen om het laatste uur te tonen. Autovacuum draaide non-stop en kwam nooit klaar. Backups duurden elf uur. Ze hadden er twee jaar lang grotere Postgres instances tegenaan gegooid en zaten nu op de grootste RDS box die AWS verkocht.

We hadden geen grotere box nodig. We hadden Rails Postgres table partitioning nodig. Twee weken later antwoordde diezelfde tabel in milliseconden, autovacuum stond bijna de hele dag stil, en een maand oude data droppen was een DETACH PARTITION in plaats van een DELETE van uren. Na negentien jaar Rails heb ik deze migratie vaak genoeg gedaan om de scherpe randen te kennen, en dit is het productie-playbook.

Wat Rails Postgres Table Partitioning Echt Doet

Postgres declarative partitioning splitst één logische tabel in vele fysieke kindtabellen op basis van een sleutel — een datumkolom voor tijdseries, een account id voor multi-tenant data, of een hash voor gelijkmatige verdeling. Je applicatie blijft de oudertabel queryen. De Postgres planner leest de partition key uit je WHERE clause en raakt alleen de partities aan die hij nodig heeft.

De winst stapelt zich op. Indexen krimpen omdat elke partitie zijn eigen index heeft over een kleinere slice. Autovacuum heeft minder te kauwen per partitie en is in minuten klaar in plaats van uren. Je kunt oude partities direct droppen of detachen in plaats van een DELETE af te vuren die de tabel opblaast. Backups kun je gelaagd maken — hete partities op snelle storage, koude partities op goedkope storage. Sequential scans, als ze gebeuren, scannen één partitie in plaats van het hele monster.

De prijs is reëel. Gepartitioneerde tabellen hebben constraints die gewone tabellen niet hebben. Foreign keys naar een gepartitioneerde tabel werken niet in oudere Postgres versies en slechts gedeeltelijk in nieuwere. Unique constraints moeten de partition key bevatten. De query planner heeft de partition key in je WHERE nodig om partities te kunnen prunen, en ActiveRecord scopes die hem weglaten scannen alles. Rails Postgres table partitioning is een van die features waarbij de operationele kost hoger is dan de schema kost, en je moet hem niet adopteren voordat je hem nodig hebt.

Wanneer Rails Postgres Table Partitioning Loont (en Wanneer Niet)

De vuistregel die ik gebruik: partitioneer wanneer de tabel boven de 100 GB of boven de 500 miljoen rijen zit of wanneer je regelmatig grote stukken op datum dropt. Onder die getallen, fix je indexen, voeg een partial index toe voor hete rijen, draai VACUUM ANALYZE, en kom er een kwartaal later op terug.

Sterke fit voor tijd-gebaseerde Rails Postgres table partitioning:

  • Append-only event streams — analytics events, webhook ontvangsten, audit logs, Sidekiq job records
  • Tijdseries telemetrie — applicatie metrics, IoT readings, request logs voor compliance
  • Notification en outbox tabellen die voor altijd groeien en vooral in een recent venster gequeryd worden
  • Soft-delete tabellen waar de meeste reads rijen uit de laatste N dagen raken
  • Per-tenant write-heavy tabellen die goed combineren met hash partitioning op account_id

Slechte fit:

  • Tabellen onder de 50 GB waar queries al goed indexen gebruiken
  • Tabellen zonder een duidelijke partition key in elke hete query
  • Tabellen met frequente updates over veel partities (de planner kan writes niet prunen)
  • Tabellen die foreign keys naar zich hebben vanuit veel andere tabellen

Als je nog op Postgres 12 of ouder zit, upgrade voordat je gaat partitioneren. Declarative partitioning vóór Postgres 13 mist te veel features (logical replication van gepartitioneerde tabellen, partities aanhechten zonder lange locks, partition-wise joins standaard aan) voor een serieuze productie-uitrol.

Tijd-Gebaseerde Rails Postgres Table Partitioning Opzetten

Laten we een hypothetische events tabel partitioneren per maand. Het schema voor een verse tabel ziet er zo uit:

class CreatePartitionedEvents < ActiveRecord::Migration[8.0]
  def up
    execute <<~SQL
      CREATE TABLE events (
        id          bigserial,
        account_id  bigint NOT NULL,
        kind        text   NOT NULL,
        payload     jsonb  NOT NULL,
        occurred_at timestamptz NOT NULL,
        created_at  timestamptz NOT NULL,
        updated_at  timestamptz NOT NULL,
        PRIMARY KEY (id, occurred_at)
      ) PARTITION BY RANGE (occurred_at);
    SQL

    execute <<~SQL
      CREATE INDEX index_events_on_account_id_and_occurred_at
        ON events (account_id, occurred_at DESC);
    SQL
  end

  def down
    drop_table :events
  end
end

Twee dingen om te benoemen. Ten eerste bevat de primary key occurred_at omdat Postgres vereist dat de partition key onderdeel is van elke unique constraint, inclusief de primary key. ActiveRecord behandelt id nog steeds als primary key voor model doeleinden, maar Postgres heeft het composiet nodig. Ten tweede worden indexen op de ouder automatisch aangemaakt op elke kindpartitie, nu en in de toekomst. Declareer ze niet per partitie.

Het bijbehorende ActiveRecord model:

class Event < ApplicationRecord
  self.primary_key = :id

  scope :for_account, ->(id) { where(account_id: id) }
  scope :in_window,   ->(range) { where(occurred_at: range) }
end

Maak nu maandpartities. De simpelste versie om mee te beginnen:

class CreateEventPartitionsForApril2026 < ActiveRecord::Migration[8.0]
  def up
    execute <<~SQL
      CREATE TABLE events_2026_04 PARTITION OF events
        FOR VALUES FROM ('2026-04-01') TO ('2026-05-01');
    SQL
  end

  def down
    execute "DROP TABLE events_2026_04"
  end
end

Dat is het. Inserts in events voor april landen in events_2026_04. Queries met occurred_at in april scannen alleen die partitie. Queries die twee maanden bestrijken scannen er twee. Queries zonder occurred_at scannen ze allemaal — schrijf je scopes daarnaar.

Productie Patroon 1: Append-Only Tijdseries Met Auto-Aangemaakte Partities

Partities met de hand maken is prima voor week één. Tegen maand drie ben je het vergeten, een insert landt buiten elke partitie, en Postgres geeft no partition of relation "events" found for row. Je background jobs gaan stuk om middernacht UTC op de eerste van de maand. Laat dit niet gebeuren.

Er zijn twee redelijke aanpakken. Plan een Rails job die partities vooruit aanmaakt, of installeer pg_partman en laat de database het doen. Ik draai beide bij verschillende klanten en ze werken allebei. De Rails-versie leest natuurlijker voor een Rails team en vereist geen extra extensie; pg_partman is meer battle-tested op schaal.

Hier is de Rails-side versie, die ik prefereer voor teams onder honderd engineers:

class Partitions::EventsCreator
  LOOKAHEAD_MONTHS = 3

  def self.ensure_future_partitions
    base = Time.current.beginning_of_month
    LOOKAHEAD_MONTHS.times do |i|
      month = base + i.months
      create_partition(month)
    end
  end

  def self.create_partition(month)
    name  = "events_#{month.strftime('%Y_%m')}"
    start = month.strftime("%Y-%m-%d")
    finish = (month + 1.month).strftime("%Y-%m-%d")

    ActiveRecord::Base.connection.execute(<<~SQL)
      CREATE TABLE IF NOT EXISTS #{name}
        PARTITION OF events
        FOR VALUES FROM ('#{start}') TO ('#{finish}');
    SQL
  end
end

Plan hem dagelijks met welke scheduler je ook vertrouwt — ik behandel de cron-overlap valkuilen in de Rails Postgres advisory locks post, en je moet dit absoluut daarin wikkelen. Drie maanden vooruitkijken betekent dat een ontspoorde scheduler je een kwartaal geeft om het te merken voordat inserts gaan falen.

Oude partities droppen is de tweede helft van de operatie:

class Partitions::EventsRetainer
  RETENTION_MONTHS = 12

  def self.detach_old_partitions
    cutoff = (Time.current - RETENTION_MONTHS.months).beginning_of_month
    partitions_older_than(cutoff).each do |name|
      ActiveRecord::Base.connection.execute(
        "ALTER TABLE events DETACH PARTITION #{name} CONCURRENTLY"
      )
      ActiveRecord::Base.connection.execute("DROP TABLE #{name}")
    end
  end

  def self.partitions_older_than(cutoff)
    sql = <<~SQL
      SELECT inhrelid::regclass::text AS name
      FROM pg_inherits
      WHERE inhparent = 'events'::regclass
    SQL
    ActiveRecord::Base.connection.select_values(sql).select do |name|
      name =~ /events_(\d{4})_(\d{2})$/ &&
        Date.new($1.to_i, $2.to_i, 1) < cutoff.to_date
    end
  end
end

DETACH PARTITION CONCURRENTLY (Postgres 14+) doet de detach zonder writers te blokkeren. Daarna is de partitie gewoon een normale tabel — je kunt hem direct DROP TABLE‘en, geen lange DELETE, geen autovacuum nasleep, geen bloat. Deze ene operatie is vaak de hele reden waarom teams Rails Postgres table partitioning adopteren.

Productie Patroon 2: Composite Partitioning per Tenant en Tijd

Voor een multi-tenant SaaS waar één grote klant je write-volume kan domineren, partitioneer eerst op account_id (met LIST of HASH) en sub-partitioneer elke tenant op tijd. De grote tenant krijgt isolatie; de kleine tenants delen. Gecombineerd met de Pundit autorisatie patronen die ik schreef voor multi-tenant SaaS en je tenancy verhaal staat van begin tot eind.

CREATE TABLE events (
  id          bigserial,
  account_id  bigint NOT NULL,
  occurred_at timestamptz NOT NULL,
  payload     jsonb NOT NULL,
  PRIMARY KEY (id, account_id, occurred_at)
) PARTITION BY LIST (account_id);

CREATE TABLE events_acct_42 PARTITION OF events
  FOR VALUES IN (42)
  PARTITION BY RANGE (occurred_at);

CREATE TABLE events_acct_42_2026_04 PARTITION OF events_acct_42
  FOR VALUES FROM ('2026-04-01') TO ('2026-05-01');

CREATE TABLE events_default PARTITION OF events DEFAULT
  PARTITION BY RANGE (occurred_at);

CREATE TABLE events_default_2026_04 PARTITION OF events_default
  FOR VALUES FROM ('2026-04-01') TO ('2026-05-01');

De afweging: meer partities, meer catalog overhead, meer voor de planner om te overwegen. Hou het totale aantal partities onder ongeveer tienduizend en hou je hete scopes beperkt tot één tenant binnen één tijdsvenster. Als je merkt dat de planner traag wordt, controleer dan dat partition_pruning = on (default) en dat je WHERE beide sleutels bevat.

Valkuilen in Rails Postgres Table Partitioning

Dit zijn de fouten die ik persoonlijk productie-incidenten heb zien veroorzaken.

Unique constraints op niet-partition kolommen breken stilletjes. Een unique_by: :external_id op een gepartitioneerde events tabel compileert prima en handhaaft uniqueness binnen elke partitie, niet globaal. Als je globaal unieke niet-key kolommen nodig hebt, kun je dat niet handhaven in een gepartitioneerde tabel — verplaats die uniqueness naar een aparte niet-gepartitioneerde reference tabel.

Foreign keys die naar een gepartitioneerde tabel wijzen waren onmogelijk vóór Postgres 12 en zijn nog steeds onhandig. Als comments.event_id naar events.id verwijst, en events is gepartitioneerd, moet Postgres bij een insert overal naar de existence check kijken. Vermijd dit ontwerp waar je kunt. Voor audit-stijl data, denormaliseer de parent reference in plaats van een echte FK te gebruiken.

ActiveRecord migraties roundtrippen niet schoon. Gepartitioneerde tabellen gebruiken SQL features die de schema dumper niet kan representeren. Zet je repo over op config.active_record.schema_format = :sql zodat db/structure.sql de partition definities correct vastlegt. Als je dit vergeet, is je test database een gewone tabel terwijl productie gepartitioneerd is. Vraag me niet hoe ik dat weet.

COPY en bulk insert negeren partition routing prestaties. Ze werken, maar voor zeer grote loads, kopieer direct naar de doelpartitie. De router voegt op schaal tientallen procenten overhead toe.

Autovacuum settings zijn per-partitie. Een drukke partitie heeft mogelijk andere settings nodig dan een slaperige historische. Het goede nieuws: je kunt ALTER TABLE events_2026_04 SET (autovacuum_vacuum_scale_factor = 0.05) doen en alleen de hete tunen. Ik ging hier diep op in in de Postgres autovacuum tuning gids.

Een Bestaande Grote Tabel Migreren naar Gepartitioneerd Zonder Downtime

De migratie zelf is het engste deel. Je kunt niet zomaar ALTER TABLE events PARTITION BY RANGE (occurred_at) op een gevulde tabel. Postgres vereist dat de gepartitioneerde ouder leeg is.

Het patroon dat ik gebruik, dat werkte op tabellen tot ongeveer een miljard rijen zonder downtime:

  1. Maak een nieuwe gepartitioneerde tabel events_partitioned met dezelfde kolommen en de partitioning scheme.
  2. Maak partities die het hele bereik van bestaande data bestrijken plus een paar maanden vooruit.
  3. Backfill in batches met een Rails job die rijen kopieert van events naar events_partitioned, geordend op occurred_at, met INSERT ... SELECT en een LIMIT. Gebruik het Rails Postgres advisory lock patroon zodat twee pods de backfill niet tegelijk draaien.
  4. Voeg een trigger toe op de originele events tabel die nieuwe inserts en updates spiegelt naar events_partitioned. Dit is het meest risicovolle stuk — schrijf eerst characterisation tests.
  5. Zodra de backfill is bijgepraat en de trigger een dag stil is geweest (rijen matchen in beide), in één enkele transactie: RENAME events naar events_old, RENAME events_partitioned naar events, drop de trigger.
  6. Sanity-check counts en een paar queries, drop dan events_old na een week.

Voor tabellen boven een paar miljard rijen, neem het maintenance window. De fintech klant waar ik mee opende nam een vier uur durend zaterdag-window met read-only modus liever dan het risico van een live cutover. Soms is de saaie keuze de juiste keuze.

De gedetailleerde neef van dit patroon is het zero-downtime database migratie playbook — zelfde principes, andere schema operatie.

FAQ

Hoeveel partities kan een Postgres tabel hebben voordat de prestaties degraderen?

In Postgres 14 en later blijft planning snel tot in de lage duizenden partities per tabel dankzij runtime pruning. Ik behandel 1.000 partities als een comfortabel plafond en 10.000 als een waarschuwingssignaal. Daarboven wordt query planning tijd zelf de bottleneck. Als je meer nodig hebt, partitioneer met grovere granulariteit (kwartaal in plaats van maand) of shard op applicatieniveau.

Moet ik pg_partman gebruiken of mijn eigen partition manager schrijven in Rails?

Beide werken. pg_partman is de juiste keuze als je operations team al Postgres extensies draait, je voorgebakken retention en pre-creation logica wilt, en je geen partitioning concerns in je Rails repo wilt. Schrijf je eigen versie als je een klein team bent dat Ruby code en bestaande observability prefereert. De Rails versie is vijftig regels en eenvoudiger te begrijpen; pg_partman handelt edge cases af waar je nog niet aan gedacht hebt.

Werkt Rails Postgres table partitioning met Active Record associaties en joins?

Ja, transparant — je queryt de oudertabel en Active Record weet of geeft niet om dat hij gepartitioneerd is. Wat je moet checken is dat elke hete query de partition key in zijn WHERE heeft zodat Postgres kan prunen. Event.where(account_id: 42).where(occurred_at: 1.day.ago..) pruned tot één partitie. Event.where(account_id: 42) alleen scant elke partitie. Voeg Bullet of strict loading toe voor hygiëne, maar partition pruning controleer je met EXPLAIN.

Kan ik partitioning toevoegen aan een tabel die al foreign keys naar zich heeft?

Het is pijnlijk. De migratie hierboven (rename-and-swap) moet elke inkomende FK opnieuw aanmaken tegen de nieuwe gepartitioneerde tabel, en events.id is op zichzelf niet meer uniek — de unique constraint bevat de partition key. In de praktijk, denormaliseer inkomende FKs naar business-key referenties (een event_uuid kolom met een applicatie-niveau invariant) voordat je partitioneert. Dit is een van de sterkste redenen om nieuwe write-heavy tabellen vanaf het begin met partitioning in gedachten te ontwerpen, zelfs als je het niet op dag één aanzet.

Hulp nodig met een Rails Postgres prestatie-migratie zonder het systeem plat te leggen? TTB Software is gespecialiseerd in Rails infrastructuurwerk zoals dit — partitioning, replication, vacuum tuning, de hele stack. We doen dit al negentien jaar.

#rails-postgres-table-partitioning #postgres-partitioning-rails #postgres-time-based-partitioning #rails-large-tables #pg-partman-rails #postgres-declarative-partitioning #ruby-on-rails
R

About the Author

Roger Heykoop is een senior Ruby on Rails ontwikkelaar met 19+ jaar Rails ervaring en 35+ jaar ervaring in softwareontwikkeling. Hij is gespecialiseerd in Rails modernisering, performance optimalisatie, en AI-ondersteunde ontwikkeling.

Get in Touch

Share this article

Need Expert Rails Development?

Let's discuss how we can help you build or modernize your Rails application with 19+ years of expertise

Schedule a Free Consultation