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.

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:
- Your dominant query predicate is a time window (
WHERE created_at BETWEEN '2027-01-01' AND '2027-01-31') - Data arrival is monotonically increasing (ledger entries, trade confirmations, settlement records)
- You need to drop or archive old data cheaply —
DETACH PARTITION+DROP TABLEis O(1) regardless of row count - 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:
- You have a low-cardinality categorical column that almost always appears in your WHERE clause (
currency,account_type,region) - You're running a multi-tenant SaaS and
tenant_idis always filtered — list partitioning by tenant eliminates cross-tenant scans entirely - 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
INSERTto fail withno partition of relation found. Automate this withpg_partman— setpremake = 3to 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:
- Create a new partitioned parent with the same column definitions. Name it
transactions_v2. - 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. - Set up a logical replication slot or an application-level dual-write so that new inserts go to both
transactions(the old table) andtransactions_v2during the backfill window. - 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.
- Atomic cutover. Inside a transaction: rename
transactionstotransactions_old, renametransactions_v2totransactions. Because PostgreSQL'sALTER TABLE ... RENAMEis metadata-only, this takes milliseconds and holds anACCESS EXCLUSIVElock for less than 50ms in practice. - Validate, then drop
transactions_oldafter 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 ScanorIndex 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. UseWHERE created_at >= '2027-01-15' AND created_at < '2027-01-16'instead. enable_partition_pruningisoff(default ison, but double-check yourpostgresql.conf).- The query is prepared (
PREPARE) and the parameter wasn't visible at plan time — runtime pruning handles this since PostgreSQL 12, but it requiresplan_cache_mode = autoorforce_generic_planto 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.005fillfactor = 80on partitions that receive UPDATEs (e.g.,settled_atbeing 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
DELETEon the old table took 38 minutes and generated 19 GB of WAL. - Partition-wise aggregate speedup:
SUM(amount) GROUP BY account_idacross three months ran 3.4× faster withenable_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 aDETACH+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.
