StormaticsStormatics

Partitioning Four PostgreSQL Tables at Scale, Without a One-Size-Fits-All Answer

How real data, not convention, shaped four different PostgreSQL partitioning strategies for the largest tables in a production database, and the two bugs we caught before they ever reached production.

Key Takeaways

  • Designed partitioning strategies for the four largest tables in a production database schema, deliberately not using the same approach for all four.
  • Foreign key dependency analysis surfaced 35 and 59 dependent relationships on two of the four tables, a hard structural constraint that determined migration order and complexity, not a matter of team preference. 
  • Real production data revealed severe skew, one event alone accounted for roughly 13% of a table’s entire volume, informing a hybrid partitioning design rather than a naive even split.
  • Built a zero-downtime migration pattern combining real-time trigger-based sync with a self-unscheduling batched backfill, so no write is ever. missed during the transition
  • Two tables with more than one foreign key relationship to the same parent were sharing a single helper column, silently corrupting one relationship’s data with another’s.

The Starting Point

This work surfaced a longer-horizon question: four of the platform’s largest tables were approaching the size where normal maintenance, index rebuilds, and even routine queries were starting to feel the weight of hundreds of millions of rows in a single, flat table. Partitioning was the obvious answer in principle. In practice, “just partition it” is not a strategy, it’s a direction, and the actual strategy depends entirely on how a specific table is shaped and queried.

So before writing a single CREATE TABLE statement, we asked two questions for each of the four tables. First, does anything else in the schema depend on this table by foreign key? Second, does the data itself have a natural, evenly distributed partition key, or does it hide the kind of skew that would make an even split perform worse than no partitioning at all?
 

Finding the Right Strategy Per Table, Not One Strategy for All

 
The instinct to check dependencies before committing to an approach is what turned what could have been a uniform, convenient plan into four genuinely different, evidence-based ones.
 

Counting how many other tables depend on a candidate via foreign key, before committing to any migration plan:

SELECT count(*)
FROM pg_constraint
WHERE contype = 'f'
   AND confrelid = '"GlobalEvent"'::regclass;

Two of the four tables came back clean: no incoming foreign keys at all, making them self-contained, low-risk candidates for a first migration. The other two told a very different story: 35 and 59 dependent relationships respectively across the schema. That is not a detail to work around after the fact; it changes the entire shape of the migration: every dependent table’s foreign key needs to be widened to include the new partition key, backfilled in batches, and only then repointed, all before the actual partitioning work can safely begin.

For the two tables where a workspace or tenant identifier looked like the natural partition key, we didn’t assume an even split would work. We pulled real production data and checked.

Checking whether workspace-based partitioning would actually distribute evenly:

SELECT
    count(*) AS distinct_workspaces,
    quantile(0.5)(cnt) AS median_rows_per_workspace,
    quantile(0.95)(cnt) AS p95_rows_per_workspace,
    max(cnt) AS largest_workspace_rows,
    sum(cnt) AS total_rows
FROM (
    SELECT eventId, count() AS cnt
    FROM sandbox.event_min
    GROUP BY eventId
);

The result was decisive: across 273 distinct workspaces, the median workspace held roughly 20,000 rows, but the single largest workspace held over 4.3 million, more than 200 times the median, and alone accounted for roughly 13% of the entire table’s volume. A straightforward hash-based split on workspace ID would have produced one wildly oversized partition and dozens of comfortably sized ones, defeating the purpose of partitioning for exactly the tenant that mattered most.

The Rest of the Toolkit

Two structurally different strategies emerged from that evidence, applied consistently across the four tables.

Range partitioning by date, sized to actual growth. For the two tables with no incoming dependencies and a clear time dimension, monthly range partitions were the natural fit, but not a flat monthly scheme from day one. Real row-count data showed a steady, multi-year growth ramp; the earliest months held a few hundred rows each, the most recent months held millions. A flat monthly split would have produced mostly empty early partitions and dramatically oversized recent ones. The final design uses one wide “legacy” partition to absorb the sparse early history, switching to true monthly partitions only from the point the data justifies it.

Hybrid list and hash partitioning, sized to the skew. For the tenant-keyed tables, the fix was a hybrid scheme: dedicated, individual partitions for the confirmed largest workspaces, so no single tenant’s data ever dominates a shared bucket. The long tail of ordinary workspaces is then distributed evenly across hash buckets, sized against their own, much flatter distribution.  Isolating the largest workspaces this way brought the remaining skew ratio down from over 200x to roughly 32x, a real, measured improvement, not a guess.

Zero-downtime migration, not a maintenance window. Every migration follows the same pattern: build the new partitioned table alongside the live one, attach a trigger that mirrors every insert, update, and delete into the new table in real time, then run a batched backfill for historical rows on a schedule that automatically stops itself once it detects nothing is left to copy. The live table never stops serving traffic, and the cutover itself is a single, brief transaction that simply renames the two tables and promotes the new one into place.

Guarding against no-op writes, sized to each table’s real update rate. The same real-time sync trigger can either write unconditionally or check first whether anything actually changed. That guard has a real cost, so rather than adding it everywhere by default, we checked each table’s actual update intensity before deciding.

Deciding, per table, whether a no-op write guard is worth its cost:

SELECT relname, n_tup_upd, n_live_tup,
    ROUND(n_tup_upd::numeric / NULLIF(n_live_tup, 0), 2)
        AS updates_per_live_row
FROM pg_stat_user_tables
WHERE relname = 'GlobalActivity';

One table came back at 0.29 updates per live row, low enough that the guard’s overhead wasn’t worth adding. The other three came back meaningfully higher, one as high as 7 updates per live row, confirming the guard would pay for itself many times over on those specific tables. Applying the same fix everywhere, regardless of whether the underlying table actually needed it, would have been the easier path and the wrong one.

An Honest Story About Getting It Right

Two real bugs were caught during this work before either could reach production, and both are worth describing plainly, because catching them is the actual value of doing this migration carefully rather than quickly.

The first was structural, and easy to miss.PostgreSQL binds a foreign key constraint and a trigger to a table’s underlying identity at the moment it’s created, not to the table’s name. An early version of the migration plan added the widened foreign key constraints on the dependent tables before the final rename-and-cutover step. That ordering looks correct on paper. In practice, once the live table is renamed aside and the newly built partitioned table is promoted into its place, any constraint created earlier stays silently bound to the old, now-retired table, not the one actually serving traffic. The fix was a straightforward reordering, moving constraint creation to after cutover, but finding it required tracing exactly how PostgreSQL tracks object identity across a rename, not just trusting that the SQL ran without error.

The second was narrower, but just as real. Two of the dependent tables each had more than one foreign key relationship pointing at the same parent table, for example, a table linking two different content records to one another. The natural approach, one helper column tracking the partition key’s value for the migration, worked fine for tables with a single relationship. For the two tables with two relationships each, both relationships pointed to the same shared helper column. So populating the second relationship’s value silently overwrote the first’s. Nothing errored. The fix was giving every distinct relationship its own uniquely named helper column, confirmed by re-checking the actual generated SQL column by column before it ran anywhere near real data.

Neither bug would have been visible from the outside until well after cutover, when referential integrity checks that looked correctly configured would quietly have been enforcing nothing, or a relationship’s data would have been wrong in a way no error message would ever surface. Catching both before a single production row moved is exactly why this kind of migration gets planned in writing, reviewed line by line, and tested against real dependency data rather than assumed correct because the syntax is valid.

Closing Thoughts

The four tables ended up with different partitioning strategies, and that conclusion only held up because skew analysis was run on the data in each one. 

If your team is looking at a table that has simply outgrown a flat schema, the right partitioning scheme is rarely the first one that comes to mind, and it’s almost never the same scheme for every table in the database. This is exactly the kind of evidence-first migration planning Stormatics does.

Leave A Comment