Chapter 7: Postgres declarative partitioning via SQLAlchemy. When to partition, how to declare it, and pg_partman for automation.
When to partition
- Tables > 100 GB.
- Heavy date-range queries.
- Need to drop old data fast (TRUNCATE / DROP partition).
- Per-tenant isolation.
For smaller: don’t bother. See Postgres Partitioning .
Declarative partitioning in SQLAlchemy
class Event(Base):
__tablename__ = "events"
__table_args__ = {
"postgresql_partition_by": "RANGE (occurred_at)",
}
id: Mapped[int] = mapped_column(primary_key=True)
occurred_at: Mapped[datetime] = mapped_column(primary_key=True)
payload: Mapped[dict] = mapped_column(JSONB)
Note: partition key must be in the primary key.
Partition definitions
SQLAlchemy doesn’t have first-class partition declarations. Create them via raw SQL in migrations:
# Alembic migration
op.execute("""
CREATE TABLE events_2026_01 PARTITION OF events
FOR VALUES FROM ('2026-01-01') TO ('2026-02-01')
""")
op.execute("""
CREATE TABLE events_2026_02 PARTITION OF events
FOR VALUES FROM ('2026-02-01') TO ('2026-03-01')
""")
pg_partman (recommended)
Manual partition creation is a chore. pg_partman automates:
CREATE EXTENSION pg_partman;
SELECT partman.create_parent(
p_parent_table := 'public.events',
p_control := 'occurred_at',
p_type := 'range',
p_interval := 'monthly',
p_premake := 4
);
A periodic maintenance job creates new partitions ahead of time:
SELECT partman.run_maintenance();
Schedule via pg_cron or external cron.
Range partitioning
By time:
CREATE TABLE events_2026_01 PARTITION OF events FOR VALUES FROM ('2026-01-01') TO ('2026-02-01');
By id ranges:
CREATE TABLE events_part_a PARTITION OF events FOR VALUES FROM (0) TO (1000000);
Common for time-series + retention.
List partitioning
__table_args__ = {"postgresql_partition_by": "LIST (region)"}
CREATE TABLE orders_us PARTITION OF orders FOR VALUES IN ('us-east', 'us-west');
CREATE TABLE orders_eu PARTITION OF orders FOR VALUES IN ('eu-west', 'eu-north');
For categorical splits.
Hash partitioning
__table_args__ = {"postgresql_partition_by": "HASH (user_id)"}
CREATE TABLE messages_p0 PARTITION OF messages FOR VALUES WITH (modulus 8, remainder 0);
CREATE TABLE messages_p1 PARTITION OF messages FOR VALUES WITH (modulus 8, remainder 1);
-- ... 8 total
Even distribution; great for high-throughput by-user. Can’t drop “old” partitions (no logical aging).
Indexes on partitioned
class Event(Base):
...
__table_args__ = (
Index("ix_events_user_occurred", "user_id", "occurred_at"),
{"postgresql_partition_by": "RANGE (occurred_at)"},
)
Postgres creates indexes on each partition. Each partition has its own (small, fast).
Querying partitions
stmt = select(Event).where(Event.occurred_at >= '2026-02-01', Event.occurred_at < '2026-03-01')
Postgres planner prunes — only scans matching partitions.
For cross-partition queries: still works, may scan many partitions.
Partition pruning verification
explain = await session.execute(text("EXPLAIN " + str(stmt.compile(compile_kwargs={"literal_binds": True}))))
for row in explain:
print(row[0])
# Look for "Append" with only relevant partitions
If query still scans all partitions: predicate doesn’t allow pruning. Common causes:
- Function on the partition key:
date_trunc('month', occurred_at). - Parameter not yet known at plan time (workaround: prepared statements with parameter inlining).
Retention via DROP
DROP TABLE events_2024_01; -- instant
vs
DELETE FROM events WHERE occurred_at < '2024-02-01'; -- hours
Drop a partition; instant. The big op win of partitioning.
Foreign keys
Foreign keys to partitioned tables: supported. Foreign keys from partitioned tables to others: supported.
Composite per-partition FKs are checked per partition. Watch insert performance.
Constraints
Each partition can have its own constraints; or inherited from parent. Standard CHECK constraints help the planner with pruning.
Migration to partitioned
Existing big table → partitioned:
- Create new partitioned table with the same shape.
- Copy data in batches (or via logical replication).
- Switch writes to new table.
- Drop old table.
Or: pg_partman 5+ has an import mode. Or: pg_repack with partitioning support.
Common mistakes
1. Partitioning small tables
Postgres handles 100 GB tables fine. Below that: partitioning is overhead with no benefit.
2. Wrong partition key
Queries don’t filter by partition key → no pruning. Identify query patterns first.
3. Manual partition creation
Forget; midnight inserts fail. Use pg_partman.
4. Hundreds of partitions
Planner overhead. Aim <100 in most cases.
5. Not using EXPLAIN
Verify pruning works. Don’t assume.
What’s next
Chapter 8: RLS and multi-tenancy.
Read this next
Building something AI-, backend-, or data-heavy and want a second pair of eyes? I do consulting and freelance work — see my projects and ways to reach me at rajpoot.dev .