Skip to content

Table Partitioning ​

Weasel supports PostgreSQL's native table partitioning via the IPartitionStrategy interface, with built-in implementations for hash, range, and list partitioning.

Hash Partitioning ​

Distributes rows across a fixed number of partitions using a hash of the partition key columns.

cs
var table = new Table("events");
table.AddColumn<Guid>("id").AsPrimaryKey();
table.AddColumn<string>("category").NotNull();
table.AddColumn("data", "jsonb");

table.PartitionByHash(new HashPartitioning
{
    Columns = new[] { "id" },
    Suffixes = new[] { "p0", "p1", "p2", "p3" }
});

snippet source | anchor

The Suffixes property automatically calculates the modulus and remainder for each partition. The resulting partition tables are named {table}_{suffix}.

Range Partitioning ​

Splits rows into partitions based on value ranges. A default partition is created automatically to catch values outside defined ranges.

cs
var table = new Table("measurements");
table.AddColumn<int>("id").AsPrimaryKey();
table.AddColumn<DateTimeOffset>("recorded_at").NotNull();
table.AddColumn<double>("value");

var partitioning = table.PartitionByRange("recorded_at");
partitioning.AddRange("q1_2024",
    DateTimeOffset.Parse("2024-01-01"),
    DateTimeOffset.Parse("2024-04-01"));
partitioning.AddRange("q2_2024",
    DateTimeOffset.Parse("2024-04-01"),
    DateTimeOffset.Parse("2024-07-01"));

snippet source | anchor

List Partitioning ​

Assigns rows to partitions based on discrete column values.

cs
var table = new Table("orders");
table.AddColumn<int>("id").AsPrimaryKey();
table.AddColumn<string>("region").NotNull();
table.AddColumn<decimal>("total");

var partitioning = table.PartitionByList("region");
partitioning.AddPartition("north", "US-NORTH", "CA-NORTH");
partitioning.AddPartition("south", "US-SOUTH", "MX");

snippet source | anchor

A default partition is enabled by default. Disable it with partitioning.DisableDefaultPartition().

ManagedListPartitions ​

For dynamic partition management (e.g., multi-tenant systems), use ManagedListPartitions. This stores partition assignments in a dedicated database table and can add or remove partitions at runtime.

cs
var table = new Table("tenanted_data");

var manager = new ManagedListPartitions(
    "tenant_partitions",
    new DbObjectName("public", "mt_tenant_partitions"));

var partitioning = table.PartitionByList("tenant_id");
partitioning.UsePartitionManager(manager);

snippet source | anchor

At runtime, add partitions across all managed tables:

cs
PostgresqlDatabase database = null!; // your database instance
ManagedListPartitions manager = null!; // your partition manager
var ct = CancellationToken.None;

await manager.AddPartitionToAllTables(database, "tenant_a", "tenant_a", ct);

snippet source | anchor

Or add multiple partitions at once:

cs
PostgresqlDatabase database = null!; // your database instance
ManagedListPartitions manager = null!; // your partition manager
ILogger logger = null!; // your logger
var ct = CancellationToken.None;

var values = new Dictionary<string, string>
{
    { "tenant_b", "tenant_b" },
    { "tenant_c", "tenant_c" }
};
await manager.AddPartitionToAllTables(logger, database, values, ct);

snippet source | anchor

Remove partitions when a tenant is deprovisioned:

cs
PostgresqlDatabase database = null!; // your database instance
ManagedListPartitions manager = null!; // your partition manager
ILogger logger = null!; // your logger
var ct = CancellationToken.None;

await manager.DropPartitionFromAllTablesForValue(database, logger, "tenant_a", ct);

snippet source | anchor

ManagedRangePartitions ​

Time-series tables -- telemetry, audit trails, events, logs -- want a partition set that moves. The declared window rolls forward every period, last period's partitions are still on disk, and the point of partitioning them at all is that retiring a period should be a DROP TABLE instead of a mass DELETE and the vacuum storm that follows.

ManagedRangePartitions owns that window. Rather than declaring a static list of ranges, you declare intent -- a period, how many periods to provision ahead, and how many to retain behind -- and Weasel writes every DDL statement:

cs
var table = new Table("metrics");
table.AddColumn<Guid>("id").AsPrimaryKey();

// PostgreSQL requires the partition key to be part of every unique
// constraint on a partitioned table
table.AddColumn<DateTimeOffset>("occurred_at").AsPrimaryKey().NotNull();
table.AddColumn<double>("value");

// One partition per month, three months provisioned ahead of now,
// six completed months retained before a partition is aged out
var manager = new ManagedRangePartitions(
    RollingWindowPolicy.Monthly(periodsAhead: 3, periodsBehind: 6));

table.PartitionByRange("occurred_at").UsePartitionManager(manager);

snippet source | anchor

RollingWindowPolicy supports Hourly, Daily, Weekly, Monthly, and Yearly windows. Weekly partitions begin on Monday by default; set FirstDayOfWeek to change that at create time. All boundary arithmetic is done in UTC.

Partition tables are named {table}_{suffix}, where the suffix encodes the period start: y2026, m202607, w20260727, d20260730, h2026073014. A DEFAULT overflow partition is always created, so a row outside the provisioned window is never rejected.

Roll the window forward and retire aged partitions at runtime:

cs
PostgresqlDatabase database = null!; // your database instance
ManagedRangePartitions manager = null!; // your partition manager
ILogger logger = null!; // your logger
var ct = CancellationToken.None;

// Create any missing periods at the leading edge and drop everything
// older than the retention floor. Idempotent, so this is safe on every
// startup and from every node
await manager.ApplyAsync(database, logger, ct);

// ...or run just one half of it
await manager.RollForwardAsync(database, logger, ct);
await manager.DropAgedPartitionsAsync(database, logger, ct);

snippet source | anchor

Both halves are idempotent and safe to run concurrently from several nodes, so calling ApplyAsync on startup and on a timer is the intended usage.

Two properties make this different from a static range declaration:

  • Rolling forward is always Additive, never Rebuild. The declarative range strategy treats "the actual database has partitions the declaration no longer names" as drift and rebuilds the table. For a rolling window that is the normal steady state, and a rebuild of a multi-gigabyte table would be triggered by nothing more than the calendar. With a partition manager attached, migration only ever adds partitions.
  • Aged partitions are a policy outcome, not drift. Retention drops them, and only ever drops partitions whose suffix this policy itself produced. A hand-created partition, or one left over from a different period size, is left strictly alone.

Because of that, a rolling time-partitioned table needs neither IgnorePartitionsInMigration nor an "externally managed" escape hatch -- so it keeps Weasel's ordering and dependency management instead of hand-writing CREATE TABLE ... PARTITION OF and DROP TABLE in application code.

WARNING

Dropping an aged partition removes its rows. That is the point -- it is what makes reclaim O(1) -- but choose periodsBehind with the retention policy you actually want.

Thread Safety ​

ManagedListPartitions uses double-checked locking with a semaphore to safely initialize the partition map from the database. It is safe to call InitializeAsync, AddPartitionToAllTables, and DropPartitionFromAllTablesForValue concurrently from multiple threads.

Delta Detection ​

Weasel detects partition changes during migration. The PartitionDelta enum indicates:

  • None -- partitions match the expected configuration
  • Additive -- new partitions can be added without rebuilding
  • Rebuild -- partition strategy changed and requires table recreation

Set table.IgnorePartitionsInMigration = true if an external tool like pg_partman manages your partitions. A table using ManagedListPartitions or ManagedRangePartitions does not need it -- those strategies are already exempt from the rebuild path.

Released under the MIT License.