All posts
Developers·· 10 min read

Postgres Partitioning for Financial Time Series (2027)

A 500 GB ledger table doesn't have to be a liability. Here's the partitioning playbook that actually ships in production fintech stacks.

By AtlasForge Financial Editorial
Postgres Partitioning for Financial Time Series (2027)

If your fintech database has a transactions or ledger table north of 100 GB, you've almost certainly already felt the pain: sequential scans that crawl, VACUUM jobs that steal I/O from live queries, and autovacuum settings copied from a Stack Overflow answer written in 2019. The uncomfortable truth is that most early-stage fintech teams reach for horizontal sharding or a time-series database before they've exhausted what declarative table partitioning inside PostgreSQL can do — and that's an expensive mistake.

This post is the guide we wish had existed when we hit 500 GB on our own internal fact table. We'll cover range vs list partitioning tradeoffs with concrete numbers, the migration path that lets you repartition a live table with zero downtime, and the EXPLAIN ANALYZE output that tells you when your plan is actually using partition pruning. Everything here is battle-tested on PostgreSQL 16 and 17.

Why Postgres Partitioning Wins Before You Reach for TimescaleDB

TimescaleDB, ClickHouse, and even DynamoDB with time-based sort keys are legitimate tools. But each one introduces operational surface area: a new query dialect, separate backup tooling, and — critically for fintech — a compliance audit trail that now spans two data stores. The Federal Reserve's guidance on data governance for payment systems makes clear that auditability and lineage matter as much as throughput.

PostgreSQL's declarative partitioning, introduced in version 10 and substantially matured through version 16, gets you:

  • Partition pruning at plan time and run time, meaning the planner eliminates irrelevant child tables before the first block is read
  • Per-partition VACUUM and ANALYZE, which breaks the "one huge table, one huge lock" problem
  • Partition-wise joins and aggregates (enabled with enable_partitionwise_join = on), which let parallel workers operate on matching partitions independently
  • Logical-replication-friendly design — you can detach, archive, and re-attach partitions without touching the parent table's replication slot

For a financial time-series workload — think transaction events, price ticks, balance snapshots — the access pattern is almost always heavily skewed toward recent data. That maps perfectly onto range partitioning by timestamp.

Range vs List Partitioning: The Decision Criteria

The debate usually collapses into a false dichotomy. In practice, most production fintech schemas use both — just at different levels of the hierarchy.

Use range partitioning when:

  1. Your dominant query predicate is a time window (WHERE created_at BETWEEN '2027-01-01' AND '2027-01-31')
  2. Data arrival is monotonically increasing (ledger entries, trade confirmations, settlement records)
  3. You need to drop or archive old data cheaply — DETACH PARTITION + DROP TABLE is O(1) regardless of row count
  4. Your retention policy has a clear boundary (e.g., 24 months hot, 36 months cold in object storage via pg_partman + pg_cron)

Use list partitioning when:

  1. You have a low-cardinality categorical column that almost always appears in your WHERE clause (currency, account_type, region)
  2. You're running a multi-tenant SaaS and tenant_id is always filtered — list partitioning by tenant eliminates cross-tenant scans entirely
  3. Regulatory requirements dictate data residency: a list partition per jurisdiction means you can physically relocate a tablespace to a compliant region without touching other partitions

Sub-partitioning (the hybrid): Range by month at the top level, list by currency or account_type at the second level. This is the pattern we use internally for the AtlasForge Financial API transaction log. A monthly partition for USD activity in January 2027 is a discrete, manageable chunk. Sub-partitioning keeps cross-currency aggregations from doing full partition scans when a query only needs EUR records.

The cutoff: if your list cardinality exceeds ~50 distinct values, list partitioning degrades into overhead. Switch to a partial index on the categorical column instead.

Anatomy of a Financial Time-Series Schema

Before talking migration, here's a representative schema for a payment ledger:

CREATE TABLE ledger_entries (
  id            BIGINT GENERATED ALWAYS AS IDENTITY,
  account_id    UUID        NOT NULL,
  currency      CHAR(3)     NOT NULL,
  amount        NUMERIC(19,4) NOT NULL,
  direction     SMALLINT    NOT NULL,  -- 1 debit, -1 credit
  status        TEXT        NOT NULL,
  created_at    TIMESTAMPTZ NOT NULL,
  settled_at    TIMESTAMPTZ,
  metadata      JSONB
) PARTITION BY RANGE (created_at);

The PARTITION BY RANGE (created_at) clause on the parent table is the only DDL change to the "public" schema. Child tables are created with CREATE TABLE ledger_entries_2027_01 PARTITION OF ledger_entries FOR VALUES FROM ('2027-01-01') TO ('2027-02-01');.

Callout: Always create the next month's partition before the current month ends. A missing partition causes INSERT to fail with no partition of relation found. Automate this with pg_partman — set premake = 3 to keep three future partitions pre-created at all times.

Indexes are defined on the parent and inherited by all children. A composite index on (account_id, created_at DESC) covers the dominant read pattern — fetching recent activity for a given account — and the planner will prune irrelevant monthly partitions before scanning even that index.

Migrating a 500 GB Fact Table: The Zero-Downtime Path

This is where most guides stop, and most teams get stuck. You have a 500 GB transactions table with no partitioning. You cannot afford a 4-hour maintenance window. Here is the exact sequence:

  1. Create a new partitioned parent with the same column definitions. Name it transactions_v2.
  2. Backfill in epoch batches. Write a job that copies rows in 1-million-row chunks, ordered by created_at. Target off-peak hours. At 500 GB and ~2,000 bytes per row average, expect roughly 250 million rows — budget 8–12 hours of backfill at modest I/O throttling.
  3. Set up a logical replication slot or an application-level dual-write so that new inserts go to both transactions (the old table) and transactions_v2 during the backfill window.
  4. Catch up the delta. Once backfill completes, replay the delta from your replication slot or reconcile the dual-write log. This window should be minutes, not hours.
  5. Atomic cutover. Inside a transaction: rename transactions to transactions_old, rename transactions_v2 to transactions. Because PostgreSQL's ALTER TABLE ... RENAME is metadata-only, this takes milliseconds and holds an ACCESS EXCLUSIVE lock for less than 50ms in practice.
  6. Validate, then drop transactions_old after 48–72 hours of clean operation.

The CFPB's examination procedures for payment processors implicitly require that schema migrations be auditable and reversible. Keeping transactions_old alive for 72 hours satisfies that bar and gives you a fast rollback path.

Reading EXPLAIN ANALYZE: Is Partition Pruning Actually Working?

Partition pruning is only useful if it's actually firing. Here's how to confirm:

EXPLAIN (ANALYZE, BUFFERS, FORMAT TEXT)
SELECT account_id, SUM(amount)
FROM ledger_entries
WHERE created_at >= '2027-01-01'
  AND created_at <  '2027-02-01'
  AND currency = 'USD'
GROUP BY account_id;

Look for two things in the output:

  • Partitions selected: 1 (out of 24) — the number in parentheses should match your total partition count. If the ratio is 24/24, pruning isn't working.
  • Parallel Seq Scan or Index Scan — a parallel seq scan on a single monthly partition is often faster than an index scan when selectivity is low, because parallel workers can split the partition's blocks. Don't reflexively add indexes.

Common reasons pruning fails:

  • The partition key column is wrapped in a function: WHERE DATE(created_at) = '2027-01-15' defeats pruning. Use WHERE created_at >= '2027-01-15' AND created_at < '2027-01-16' instead.
  • enable_partition_pruning is off (default is on, but double-check your postgresql.conf).
  • The query is prepared (PREPARE) and the parameter wasn't visible at plan time — runtime pruning handles this since PostgreSQL 12, but it requires plan_cache_mode = auto or force_generic_plan to be off.

Operational Cadence: VACUUM, Bloat, and Retention

Partitioning changes the VACUUM calculus dramatically. With a monolithic 500 GB table, autovacuum processes dead tuples across the entire table in one long pass, competing with live queries for shared buffers. With monthly partitions, autovacuum operates on partitions independently. A busy January partition gets vacuumed aggressively; a read-only March partition from three years ago barely needs attention.

Recommended per-partition settings for high-write fintech workloads:

  • autovacuum_vacuum_scale_factor = 0.01 (trigger VACUUM at 1% dead tuples, down from the default 20%)
  • autovacuum_analyze_scale_factor = 0.005
  • fillfactor = 80 on partitions that receive UPDATEs (e.g., settled_at being filled in post-settlement)

For retention, the operational win is dramatic. Dropping data older than 24 months is a single DDL statement:

ALTER TABLE ledger_entries DETACH PARTITION ledger_entries_2024_11;
DROP TABLE ledger_entries_2024_11;

This is O(1) — it does not scan or log individual rows. Compare this to a DELETE WHERE created_at < '2024-12-01' on a monolithic table, which generates enormous WAL traffic, bloats the table, and triggers a multi-hour VACUUM.

If you need to archive rather than drop, detach the partition, run pg_dump to object storage (S3 or GCS), verify the checksum, then drop. The Bloomberg markets data infrastructure team wrote in 2025 about using exactly this pattern for tick-data retention at petabyte scale — partition detach makes the "hot to cold" boundary operationally clean.

Performance Numbers You Can Expect

These figures come from our internal benchmarks on a 16-core, 128 GB RAM instance running PostgreSQL 17, with a 500 GB ledger table partitioned monthly:

  • p99 query latency for single-month range scans: dropped from 4,200ms (monolithic table, index scan) to 310ms (partitioned, index scan on child) — a 13× improvement.
  • Nightly VACUUM duration: from 47 minutes (full table) to an average of 2.1 minutes per active partition, running in parallel.
  • Cold-data archival: dropping a 21 GB monthly partition takes 80ms. The equivalent DELETE on the old table took 38 minutes and generated 19 GB of WAL.
  • Partition-wise aggregate speedup: SUM(amount) GROUP BY account_id across three months ran 3.4× faster with enable_partitionwise_aggregate = on, using eight parallel workers each handling one partition's worth of data.

According to the 2026 Stack Overflow Developer Survey, PostgreSQL is the most-used database among professional developers for the fourth consecutive year — meaning operational knowledge is broadly transferable and hiring risk is low. That's a legitimate factor in the fintech build-vs-buy calculus.

Putting It Together in Production

Postgres partitioning for financial time series isn't a silver bullet, but it's the right first bullet. Before you containerize a second database engine, before you negotiate a TimescaleDB enterprise contract, run this checklist:

  • Is your dominant query predicate a time window? Range partitioning will help.
  • Do you have a low-cardinality categorical column in nearly every query? Add list sub-partitioning.
  • Is your VACUUM taking more than 10 minutes? You're already paying the cost of not partitioning.
  • Is your retention operation a DELETE? It should be a DETACH + DROP.
  • Have you confirmed partition pruning is firing with EXPLAIN (ANALYZE, BUFFERS)? If not, start there.

If you're building on top of our infrastructure, the AtlasForge Financial API exposes a ledger data layer that implements this exact partitioning strategy — monthly range partitions, sub-partitioned by currency, with automated archival via pg_partman. You don't have to manage any of this yourself; the data model is documented in our developer portal and the operational runbooks ship with every integration. For teams building their own stack, our platform overview covers how we handle schema migrations and zero-downtime deployments across our internal services — concrete patterns you can adapt directly.

The database layer is where fintech products either earn or lose engineering credibility. Get the partitioning right early, and every downstream performance conversation gets easier.

Further reading

Ready to build on AtlasForge?

Get sandbox API keys in 60 seconds — or install the Safe to Spend 365 app.