Skip to content

Custom calendars, names and codecs

The built-in calendars, names and codecs cover most tables. When they do not — a fiscal year, a legacy naming convention, a Snowflake id — plug your own in. Three extension points, from the most common to the rarest.

A custom calendar or naming: PeriodCalculator

TimeBoundaries(calculator=…) accepts any period calculator. Subclass a built-in one to change the names, or BasePeriodCalculator to change the periods themselves.

Keep a legacy naming convention

A previous manager named weeks by their Monday (events_20260824). New partitions should match:

import re
from datetime import date

from pg_partsmith import Period, TimeBoundaries, WeekPeriodCalculator


class MondayNamedWeeks(WeekPeriodCalculator):
    _NAME_PATTERN = re.compile(r"^(.+)_(\d{4})(\d{2})(\d{2})$")

    def format_partition_name(self, table_name: str, period: Period) -> str:
        return f"{table_name}_{period.to_date():%Y%m%d}"

    def _period_from_match(self, match: re.Match[str]) -> Period:
        monday = date(int(match.group(2)), int(match.group(3)), int(match.group(4)))
        iso_year, iso_week, _ = monday.isocalendar()
        return Period(year=iso_year, week=iso_week)


boundaries = TimeBoundaries(calculator=MondayNamedWeeks())

_NAME_PATTERN's first group is the table name; parse_partition_name is what recognises a detached orphan by name. Existing attached partitions are matched by bounds either way — the naming only decides what new partitions are called and which orphans are ours.

A fiscal year

import re
from datetime import datetime
from typing import ClassVar

from pg_partsmith import Period
from pg_partsmith.strategies import BasePeriodCalculator


class FiscalYearPeriodCalculator(BasePeriodCalculator):
    """Yearly partitions aligned to a fiscal year starting April 1."""

    _NAME_PATTERN: ClassVar[re.Pattern[str]] = re.compile(r"^(.+)__fy(\d{4})$")

    def period_at(self, instant: datetime) -> Period:
        local = self._local(instant)
        return Period(year=local.year if local.month >= 4 else local.year - 1)

    def format_partition_name(self, table_name: str, period: Period) -> str:
        return f"{table_name}__fy{period.year}"

    def _period_from_match(self, match: re.Match[str]) -> Period:
        return Period(year=int(match.group(2)))

    def period_start(self, period: Period) -> datetime:
        return datetime(period.year, 4, 1, tzinfo=self.tz)

    def get_boundaries(self, period: Period) -> tuple[str, str]:
        return self._encoded_boundaries(period, lambda d: d.strftime("%Y-%m-%d"))

What each method is for:

Method Role
period_at(instant) which period holds an instant — the cursor, a CreateUntil horizon, the start of a partition read from the catalog
period_start(period) the instant a period begins; must agree with get_boundaries, because the planner compares instants decoded from the catalog with the ones the calendar produces
get_boundaries(period) the two literals; _encoded_boundaries routes through the codec when one is configured
format_partition_name / _period_from_match the name and its inverse
own_name_budget() (optional) bytes the suffix adds, for the 63-byte check; a generous default is assumed otherwise

The full protocol, for an implementation that does not subclass BasePeriodCalculator:

class MyCalculator:
    def current_period(self) -> Period: ...
    def period_at(self, instant: datetime) -> Period: ...          # optional but recommended
    def next_periods(self, count: int) -> list[Period]: ...
    def period_before(self, reference: Period, offset: int) -> Period: ...
    def format_partition_name(self, table_name: str, period: Period) -> str: ...
    def parse_partition_name(self, partition_name: str) -> Period | None: ...
    def get_boundaries(self, period: Period) -> tuple[str, str]: ...

Without period_at, positions are found by walking from the current period one step at a time — fine near now, slow for a horizon years away.

Every built-in calculator takes tz (datetime.UTC, or a keyed ZoneInfo); HOUR is UTC-only. Keep the repository's ddl_timezone aligned.

A custom key encoding: RangeBoundaryCodec

A codec turns period instants into the literals of an encoded key and back. Two methods:

from datetime import UTC, datetime

from pg_partsmith import PartitionGranularity, TimeBoundaries


class SnowflakeBoundaryCodec:
    EPOCH_MS = 1_288_834_974_657          # Twitter epoch

    def encode(self, start: datetime, end: datetime) -> tuple[str, str]:
        return str(self._id_at(start)), str(self._id_at(end))

    def decode(self, literal: str) -> datetime | None:
        try:
            ms = (int(literal) >> 22) + self.EPOCH_MS
        except ValueError:
            return None
        return datetime.fromtimestamp(ms / 1000, tz=UTC)

    def _id_at(self, instant: datetime) -> int:
        return (int(instant.timestamp() * 1000) - self.EPOCH_MS) << 22


TimeBoundaries(granularity=PartitionGranularity.DAY, codec=SnowflakeBoundaryCodec())

Two rules make a codec correct: encode is monotonic in its argument, and adjacent periods are contiguous — the upper literal of one period is the lower literal of the next, no gap, no overlap. decode returns None for anything carrying no instant (MINVALUE, MAXVALUE, a literal of another type) rather than raising, so a table with a mixed history can still be introspected. Use the same codec on the query side to turn time filters into key ranges.

Built-in codecs are addressable by name in serialized configs ("uuidv7", "epoch_seconds", "epoch_milliseconds"); a custom one is an object and is left out of model_dump.

A whole axis: RangeBoundaries

When the axis is neither a calendar nor integers, implement the protocol TimeBoundaries and NumericBoundaries implement:

class RangeBoundaries(Protocol):
    axis: Axis                                   # TIME or INTEGER
    cursor_source: CursorSource                  # where "now" comes from
    def window_at(self, position) -> Window: ...
    def shift(self, window: Window, offset: int) -> Window: ...
    def literals(self, window: Window) -> tuple[str, str]: ...
    def decode(self, literal: str): ...          # a position, or None
    def child_name(self, parent_relname: str, window: Window) -> str: ...
    def parse_child_name(self, relname: str) -> Window | None: ...
    def describe(self, window: Window) -> str: ...

The contract: windows tile the axis with no gap and no overlap, shift walks them in order, decode inverts literals closely enough for equality, child_name is deterministic. Pass the instance as RangePartitioning(boundaries=…); add own_name_budget() if the names are long.

Hash and list names

HashPartitioning(name_suffix="_h{remainder}") and ListPartitioning(name_suffix="_{name}") change how members are named; {remainder} / {name} must appear, and the rest is lowercase identifier characters. List members are matched by the values they own, so a suffix change only affects new members.