The database partitioning decision record: why the partition strategy you chose determines your query pruning ceiling and your data lifecycle management surface
Database partitioning decisions are made during three sessions that never document the strategy — the schema design session that chooses a partition key based on the most obvious current access pattern without enumerating future query shapes, the data lifecycle session that adds partitioning to solve DELETE performance without evaluating the cross-partition aggregate queries running monthly, and the storage scaling session that adds hash partitions for parallel scan without recording the statistics maintenance overhead or the partition count ceiling. What none of these sessions produce is the query pruning rationale (which queries benefit from pruning and which do not, and how the partition key choice determines that), the access pattern inventory for cross-partition queries (the queries that must scan all partitions and their performance ceiling as partition count grows), or the partition maintenance model (creation schedule, statistics, index propagation, and the lifecycle of individual partitions) — the gaps that determine whether your partition strategy delivers measurable query pruning or just divides your data into isolated silos that are harder to maintain than the original table.
The 36-partition table where pruning never fired
A SaaS company's events table grows to 200 million rows over two years. Every user interaction — page views, clicks, feature usage, API calls — is recorded in a single table with columns for org_id (the tenant identifier), user_id, event_type, created_at, and a metadata JSON blob. The table is partitioned by month on created_at when it reaches 50 million rows, based on a conversation with Claude: "Our events table is getting large, should we partition it?" Claude suggests RANGE partitioning on created_at with monthly partitions as a common pattern for time-series data. The team implements it. The immediate benefit is clear: old monthly partitions can be detached and archived to cold storage without running a DELETE across hundreds of millions of rows.
Three years later, the table has 36 monthly partitions and is performing worse than expected. The primary query pattern — the dashboard query that the product's front page loads for every user — is: SELECT event_type, COUNT(*) FROM events WHERE org_id = ? AND created_at > NOW() - INTERVAL '90 days' GROUP BY event_type ORDER BY COUNT(*) DESC. This query filters on both org_id and created_at, but the partition key is created_at. The Postgres query planner evaluates the created_at predicate and correctly prunes to the 3 most recent monthly partitions (the 90-day window spans parts of 3 months). Then, within those 3 partitions, it filters on org_id as a re-check on each row it reads. Three partitions instead of 36 — a 12x reduction in the data scanned. Pruning is working exactly as designed for this query.
The problem is a second, less-examined query pattern. The billing system queries events monthly for usage-based billing calculations: SELECT COUNT(*) FROM events WHERE org_id = ? AND event_type = 'api_call' — without a created_at predicate, because the billing system pulls the count for the current billing period, which is already filtered at the application layer through a JOIN to the billing_periods table rather than a WHERE clause on created_at. The application-side JOIN means the SQL that reaches the database has no time predicate. The query planner sees a filter on org_id and event_type, neither of which is the partition key. It must scan all 36 partitions, applying the org_id and event_type filters in each. EXPLAIN ANALYZE shows 36 "Seq Scan on events_YYYY_MM" nodes, 35 of which return zero rows for any given billing query because the billing period filter is applied in application code after the database returns results. The database is doing 36 sequential scans to return approximately 3 months of data.
The billing query runs 40,000 times per day across all tenants at the start of each billing cycle. The per-query overhead — 36 partition scans rather than 3 — is small for any individual query, but across 40,000 executions it represents a sustained I/O load that competes with the primary dashboard query workload during the monthly billing cycle. The engineering team that partitioned the table documented the partition key (created_at) and the lifecycle benefit (monthly archive). They did not document which queries would not benefit from pruning or what the cross-partition query load would be as partition count grew. A query pattern that accounted for 5% of query volume at table creation was not in anyone's mind when the partition strategy was chosen. At 36 partitions, that 5% has become a billing-cycle throughput problem.
The fix — rewriting the billing query to include a created_at BETWEEN predicate derived from the billing period dates — allows the planner to prune correctly. But the rewrite requires changing the billing system's query generation logic, which had been generating the no-date-predicate query for three years across multiple services. The decision that was never written down was the complete access pattern inventory at the time of partitioning: which queries would prune, which would not, and what the operational consequence of the non-pruning queries would be as partition count accumulated over time.
The regulatory report that got slower after partitioning
A fintech company's transactions table reaches 500GB. The data retention policy requires keeping 7 years of transactions for regulatory compliance, but transactions older than 90 days are accessed infrequently — only by the compliance team running quarterly and annual reports. The primary workload (real-time transaction processing and daily reconciliation) reads only the last 30 days. The operational pain point is the monthly data purge: DELETE FROM transactions WHERE transaction_date < NOW() - INTERVAL '2555 days' (7 years) takes 4 hours on a 500GB table because DELETE is a row-by-row operation that writes to the WAL, creates dead tuples, and requires VACUUM to reclaim space. The monthly purge is affecting production write throughput during its 4-hour window.
The team partitions the transactions table by month on transaction_date. The DELETE problem is immediately solved: dropping the oldest monthly partition when it ages past 7 years is a single metadata operation that takes under a second, regardless of how many rows the partition contains. The real-time transaction processing workload benefits from partition pruning — queries filtered on the last 30 days prune to 1-2 monthly partitions from the current 84 (7 years × 12 months). The architecture is a clear improvement for the two use cases it was designed for.
Six months after partitioning, the financial regulator issues a new reporting requirement: a quarterly merchant category analysis — total transaction volume and count grouped by merchant_category_code (an 18-value enum) across all transaction dates in the reporting period — covering the full 7-year data retention window. The compliance team runs this query. It executes against all 84 partitions with no date predicate (the reporting period is all 7 years), aggregates merchant_category_code, and produces a 2-page summary report. The query took 6 minutes on the unpartitioned 500GB table. After partitioning, it takes 22 minutes.
The reason for the slowdown is the cross-partition execution overhead. The unpartitioned 500GB table was a single sequential scan — one B-tree traversal of the table, one pass through the aggregation operator, one result. The 84-partition table requires 84 sequential scans (one per partition), 84 sort operations to prepare for the aggregation within each partition, and a merge step to combine 84 partial aggregation results. The total data read is the same — 500GB — but the orchestration overhead of 84 separate scan-sort-aggregate operations is greater than the single-operation overhead on the unpartitioned table. Parallel query execution could help — running multiple partition scans simultaneously — but the database's parallel worker budget is shared across all concurrent queries, and on the primary (which handles real-time transaction writes), the parallel worker allocation for the compliance report competes with the transaction processing workload.
The compliance team runs this query quarterly from the primary because it needs current data. Moving it to a read replica would address the resource competition with write workload but would not change the 22-minute execution time — the partition count and merge overhead are the same on any replica. Caching the result would address the repeat-execution cost but the report is generated quarterly on different date ranges, making cache reuse minimal. The correct architectural solution — creating a pre-aggregated materialized view on merchant_category_code rolled up daily — would have been the right approach for the regulatory query from the beginning, and would have been obvious at partitioning time if the cross-partition query inventory had included the compliance team's reporting patterns.
The engineering leader inheriting this system finds a partitioned table that performs well for real-time transaction queries, solved the DELETE problem, but performs worse than the original table for the compliance team's most important quarterly report. The partitioning decision was correct for the problem it was designed to solve. The failure was not documenting the problem it would create for cross-partition aggregate queries, which were running on the original table and were not inventoried when the partition strategy was chosen.
Three structural properties the database partitioning decision determines
1. The partition type and the query pruning model
Postgres declarative partitioning supports three partition types, each with different pruning semantics. RANGE partitioning assigns rows to partitions based on a contiguous range of the partition key value. The partition key is typically a timestamp (transaction_date, created_at) or an integer sequence. The planner prunes RANGE partitions when the query predicate constrains the partition key to a range that intersects only a subset of partition bounds. A query with WHERE created_at BETWEEN '2025-01-01' AND '2025-03-31' on a table with monthly partitions prunes to January, February, and March 2025 — the planner evaluates each partition's range bounds (January: ['2025-01-01', '2025-02-01'), February: ['2025-02-01', '2025-03-01'), etc.) against the predicate and eliminates all partitions with non-overlapping bounds. The EXPLAIN output for a well-pruned query shows far fewer "Seq Scan" or "Index Scan" nodes than the total partition count.
LIST partitioning assigns rows to specific named partitions based on exact values of the partition key. A partition key of region with values 'us-east', 'eu-west', 'ap-south' creates three named partitions, each holding rows from the corresponding region. The planner prunes LIST partitions when the query predicate specifies exact values for the partition key — WHERE region = 'eu-west' executes only on the eu-west partition. LIST partitioning is appropriate for low-cardinality categorical values where each value group has distinct operational characteristics: a regulatory requirement that EU data reside on EU-region storage, a business requirement that certain tenants be isolated for SLA reasons, or a sharding precursor where each list partition can eventually be migrated to a dedicated instance. LIST partitioning is not appropriate for high-cardinality values (partitioning by user_id with millions of distinct values produces millions of partitions, which is operationally unmanageable) or for values with no meaningful grouping semantics.
HASH partitioning distributes rows across a fixed number of partitions based on the hash of the partition key modulo the partition count. A HASH partition on user_id with 16 partitions places each row in partition hash(user_id) % 16. HASH partitioning enables the query planner to prune to exactly one partition for a query that specifies an exact equality predicate on the partition key: WHERE user_id = 12345 can be directed to the single partition that contains rows with hash(12345) % 16 as its modulus. For all other query patterns — range predicates on the partition key, predicates on non-partition-key columns, aggregate queries without a partition key predicate — HASH partitioning provides no pruning. The primary benefit of HASH partitioning is even data distribution across partitions for parallel scan workloads and the ability to VACUUM, ANALYZE, and reindex each partition independently, distributing maintenance I/O across the partition set. HASH partitioning is difficult to evolve: adding partitions requires redistributing all existing data (because the hash modulus changes), which is a large and disruptive operation.
A fourth option — composite partitioning — uses two levels of partition hierarchy: a primary RANGE or LIST partition on the first key, with each primary partition further sub-partitioned by HASH or LIST on a second key. Composite partitioning can enable pruning on the primary key (time range) and even distribution within each primary partition (by tenant or category hash). The operational complexity is substantially higher — N × M sub-partitions must each be created, maintained, and monitored. Composite partitioning is appropriate only when neither a single RANGE nor a single LIST partition scheme can serve the primary and secondary query patterns simultaneously. The database sharding decision is the architectural alternative to composite partitioning for extreme scale: rather than adding partition hierarchy depth within a single instance, sharding distributes partitions across instances, which allows partition-per-instance operational isolation and independent write throughput scaling.
2. The cross-partition query model and the aggregate performance ceiling
Every partition strategy creates a class of queries that cannot benefit from pruning: queries that filter on non-partition-key columns, aggregate queries with no partition key predicate, and queries whose partition key constraint is determined at execution time by a subquery or parameter not known at plan time. These cross-partition queries are the performance risk that accumulates as partition count grows. Understanding which queries fall into this class and at what partition count their performance becomes unacceptable is the most important analysis the partitioning decision record must capture.
Cross-partition query execution in Postgres uses an append node in the query plan: the executor executes each partition's subplan in sequence (or in parallel if parallel append is enabled), and the results are combined through a merge or aggregation node. The overhead of cross-partition execution has three components. First, per-partition plan overhead: each partition is treated as a separate relation, with its own statistics lookup, its own cost estimation, and its own execution context initialization. For a 100-partition table, this overhead is applied 100 times. Second, per-partition sort overhead: if the query requires ORDER BY, each partition's results may need to be sorted before merge — 100 sort operations rather than one, each on a subset of the data. For aggregation queries (GROUP BY), each partition executes the aggregation independently, and the 100 partial aggregation results are merged. Third, merge overhead: combining N partial results into a final result adds O(N log N) comparison operations for sort-merge. For aggregation, the merge is typically a hash join over the partial aggregation results.
The cross-partition performance ceiling depends on the query type. For simple COUNT(*) aggregations with no GROUP BY, the per-partition overhead is small and the merge is trivial — 100 partial counts summed. This query scales well with partition count. For GROUP BY aggregations where the group cardinality is high and each partition produces many partial aggregation groups, the merge step must combine N × K partial groups (N partitions, K groups each), which is expensive for large N and K. For ORDER BY LIMIT queries where the application needs the top-K rows across all partitions, each partition must execute and sort to return up to K rows, and the merge must find the global top-K from N × K candidates. An ORDER BY created_at DESC LIMIT 10 query on a 36-partition table requires each partition to return up to 10 rows (36 execution nodes), and the merge to find the 10 most recent rows from up to 360 candidates. The per-partition overhead dominates at small K.
The practical consequence: cross-partition queries that were fast on an unpartitioned table can become dramatically slower after partitioning if the partition count is high and the query pattern does not prune. The performance degradation is not visible in the initial deployment, when the partition count is small (2-3 partitions for a recently-partitioned table), and only emerges as the partition count grows over months or years. Documenting the cross-partition query inventory at partition creation time — and projecting the performance ceiling at the expected steady-state partition count (36 monthly partitions over 3 years, 84 monthly partitions over 7 years) — is the analysis that prevents the billing query and compliance report surprises described in the opening stories.
The read replica routing decision interacts with cross-partition queries: routing analytics and compliance queries to a read replica isolates their I/O from the write primary, but does not reduce the cross-partition execution overhead. A cross-partition query that takes 22 minutes on the primary takes 22 minutes on the replica as well — the partition count and merge overhead are identical. Replica routing is the correct solution for resource isolation (preventing the compliance report from competing with real-time writes), but it is not a substitute for the correct partition strategy for the cross-partition query workload. If cross-partition queries are expected to be a significant fraction of query volume, a pre-aggregated materialized view or a dedicated analytics table may be the correct architectural complement to the partitioned base table.
3. The partition maintenance model and the operational surface
A partitioned table requires ongoing maintenance operations that an unpartitioned table does not. The operational surface of a partition strategy is the sum of these recurring tasks: partition creation, statistics collection, index management, VACUUM scheduling, and the lifecycle operations (detach, archive, drop) for aging partitions. The operational surface grows with partition count, and the maintenance burden must be accounted for in the initial partitioning decision.
Partition creation for RANGE-partitioned time-series tables must happen before data arrives. If the partition for February 2026 does not exist when the first February row is inserted, Postgres routes the row to the default partition (if configured) or rejects the insert with an error (if no default partition exists). The default partition is a safety net — it catches rows that fall outside all defined partition ranges — but it accumulates rows from multiple time periods, defeating the lifecycle management purpose of partitioning. The partition creation schedule must run reliably: a cron job that creates the next 3 monthly partitions on the 15th of each month is a common pattern. This cron job is infrastructure that must be monitored, alerted on failure, and included in disaster recovery runbooks. A failure to create next month's partition is a time-sensitive incident: inserts begin failing or routing to the default partition on the first day of the uncreated month.
Statistics collection for partitioned tables requires per-partition ANALYZE. The partitioned parent table has no heap of its own — it is a routing structure — and does not have statistics that the query planner can use. The query planner uses statistics from the individual child partitions to estimate row counts, column correlations, and histogram buckets for partition-specific queries. When a new partition is created and populated with data for the first time, it has no statistics until ANALYZE runs on it. The query planner uses default statistics estimates (assuming uniform distribution, no correlations) for the new partition until ANALYZE completes. A new partition that receives a month's worth of data before its first ANALYZE may be planned with severely inaccurate cost estimates, producing poor query plans — sequential scans where index scans are appropriate, or vice versa. AUTOVACUUM triggers ANALYZE when the partition reaches a sufficient threshold of new rows, but the threshold is configurable and may not fire until the partition has accumulated more rows than optimal. The connection pool and AUTOVACUUM configuration must account for the partition-level statistics collection frequency, particularly for high-write partitions that receive millions of rows per day.
Index management on partitioned tables requires understanding the partition index inheritance model. In Postgres, an index created on the partitioned parent table (CREATE INDEX ON events (org_id, created_at)) automatically propagates to all existing partitions and to all future partitions created via CREATE TABLE ... PARTITION OF. This is the correct approach for indexes that should apply to all partitions uniformly. The index on each partition is a fully independent B-tree index, with its own page files and its own VACUUM and bloat behavior. If a partition accumulates high dead tuple counts (due to high update or delete rates on that partition), the partition-level index bloat must be managed independently — REINDEX CONCURRENTLY on the specific partition, not on the parent table. The REINDEX on the parent creates a new index on the parent and each partition simultaneously, which is an ACCESS EXCLUSIVE lock on all partitions during the build. Use REINDEX CONCURRENTLY on the specific partition to avoid the table-level lock.
The migration strategy for a table that needs to be partitioned after it has grown to production scale is one of the most operationally complex database operations. An existing large table cannot be converted to a partitioned table in place — partitioning is a structural property set at table creation. The migration requires creating a new partitioned table with the same schema, bulk-inserting data from the original table into the new partitioned table partition-by-partition, managing the dual-write period where writes go to both tables to prevent data loss, and swapping the table references. This process typically takes hours to days for a 500GB table and requires careful coordination with the application deployment. The complexity of the migration is a strong argument for making the partitioning decision when a table is first created rather than retrofitting it after the table has grown — the initial migration from a 100-row table to a partitioned table is a 10-second operation; the migration from a 500GB table is a multi-day project.
Five ADR sections the database partitioning decision record needs
1. Partition type and key selection rationale
Document the partition type chosen (RANGE, LIST, HASH, or composite), the partition key column or expression, the rationale for why this key and type serves the primary query access pattern, and the queries for which partition pruning will be effective versus ineffective.
The key selection rationale requires enumerating the primary query patterns and evaluating each against the proposed partition key. "RANGE partitioning on created_at for time-series data" is not a rationale. A rationale specifies: which queries benefit from pruning on this key, the expected pruning fraction at steady-state partition count (a 90-day query window on 36 monthly partitions prunes 33 of 36 — 92% pruning), which queries do not prune on this key and why (billing queries filtered only on org_id without a time predicate must scan all partitions), the access frequency of non-pruning queries and their performance expectation at steady-state partition count, and the alternatives considered (partitioning on org_id via LIST, which would prune the billing query but would require one partition per tenant — unmanageable at thousands of tenants — or hash partitioning on org_id, which would prune point lookups by tenant but not time-range queries that are 95% of volume). Document why the chosen key best serves the overall query distribution, not just the most obvious query.
The partition granularity for RANGE partitions — monthly vs. quarterly vs. annually — should be documented with the rationale based on the write volume per time period (monthly granularity makes sense when each partition receives a manageable volume of rows; weekly granularity makes sense for very high write volume tables where monthly partitions would still be too large for fast DROP operations), the retention policy (quarterly granularity aligns with a quarterly archive schedule), and the cross-partition overhead at steady-state count (36 monthly partitions at 3 years is manageable; 156 weekly partitions at 3 years produces a higher cross-partition overhead for any full-scan query). The granularity should reflect the unit at which lifecycle operations (archive, drop) naturally occur.
2. Cross-partition query inventory
Enumerate the queries that do not constrain the partition key and must execute across all partitions. For each cross-partition query: the query description, the columns filtered, why the partition key is not constrained (the filter is on a different column, the filter is applied at the application layer, the predicate requires a runtime evaluation), the current execution time on the unpartitioned table or at the initial partition count, and the projected execution time at steady-state partition count.
The projected execution at steady-state partition count is the most important analysis. A cross-partition query that takes 500ms at 3 partitions (early in the table's life) may take 6,000ms at 36 partitions (3 years later) — not because the data volume grew proportionally, but because the per-partition overhead accumulated. The projection requires understanding the per-partition overhead (not the data volume): if each partition adds 50ms of orchestration overhead independent of data volume, a query that prunes 0 of 36 partitions has 1,800ms of orchestration overhead alone, before any data is read. Running EXPLAIN ANALYZE with a simulated large partition count (by creating 36 empty partitions and observing the plan node count and estimated overhead) can provide early signal on whether the cross-partition query inventory is acceptable at steady state.
The cross-partition query inventory must be revisited when new features or reporting requirements are added to the system. The compliance reporting requirement in the fintech story was a new requirement added after partitioning; if the inventory had been maintained as a living document, the new requirement would have been evaluated against it and the architectural implications (pre-aggregated view, read replica routing, query rewrite) could have been addressed proactively. The partition strategy ADR is the document against which new query patterns should be validated.
3. Data lifecycle management model
Document the data retention policy, the partition granularity that implements it, the partition creation schedule, the archive procedure (detach partition, tablespace to cold storage, or export to object storage), the drop schedule, and the default partition policy.
The default partition policy is the safety mechanism for out-of-range inserts. For RANGE-partitioned tables with a partition creation cron job, a default partition prevents insert failures if the cron job misses a cycle — out-of-range inserts accumulate in the default partition rather than failing. The default partition must be monitored: if it accumulates rows, it indicates either a cron job failure (rows from a missing monthly partition) or an application bug (rows with future or past timestamps outside the retention window). A monitoring query — SELECT tableoid::regclass, COUNT(*) FROM events WHERE tableoid = 'events_default'::regclass GROUP BY tableoid — should alert if the default partition row count grows above a threshold. Without monitoring, the default partition can silently accumulate rows from a failed partition creation, and those rows are excluded from the time-range queries that filter on the monthly partitions, producing incorrect query results without any error or warning.
The archive procedure must be documented as a runbook, not just as a policy. "Archive partitions older than 90 days to cold storage" is a policy. A runbook specifies: the SQL commands to detach the partition (ALTER TABLE events DETACH PARTITION events_2024_01), the tablespace change that moves the detached partition to cold-storage-backed tablespace (ALTER TABLE events_2024_01 SET TABLESPACE cold_storage), the verification step that confirms the detached partition is no longer included in queries on the parent table (run a query with a created_at predicate in 2024-01 and verify EXPLAIN shows the detached partition is not in the plan), and the eventual DROP TABLE command when the partition ages past the legal retention window. The backup strategy must account for partitioned tables: a logical backup (pg_dump) includes all partitions and their data; a backup that captures the parent table schema but excludes detached partitions will miss detached partition data in the restore, which may violate the retention policy if detached partitions are considered part of the live dataset.
4. Statistics and index management
Document the AUTOVACUUM configuration for each partition tier (hot partitions with high write rate vs. cold partitions with no writes), the ANALYZE schedule for new partitions, the index propagation policy, and the index maintenance procedure for per-partition bloat.
Hot partitions — the current month or the most recent partitions that receive most writes — should have AUTOVACUUM tuned more aggressively than the Postgres defaults: autovacuum_vacuum_scale_factor lowered from 0.2 to 0.05 or below, and autovacuum_vacuum_cost_delay lowered to run VACUUM more frequently. Cold partitions — historical partitions that receive no writes — should have AUTOVACUUM disabled (autovacuum_enabled = false) once the partition is detached from the parent or once the last write to that partition is confirmed. Running AUTOVACUUM on a partition that has had no writes for 6 months is wasted I/O — the partition's dead tuple count is zero and has not changed since the last VACUUM ran. The cost of disabling autovacuum on cold partitions is that any future writes (a correction import, a backfill) will not trigger automatic vacuuming; the runbook for cold partition modifications must include a manual VACUUM after the modification.
Index propagation behavior must be documented: indexes created on the partitioned parent table propagate to new partitions going forward, but indexes on existing partitions must be created explicitly if they were not defined on the parent before the partitions were created. When a new index is added to the parent table via CREATE INDEX ON events (org_id) — without CONCURRENTLY — Postgres creates the index on the parent and all existing partitions using ACCESS EXCLUSIVE locks on each partition in sequence. The total lock duration is the sum of the lock durations across all partitions. For a table with 36 partitions, each taking 10 seconds to index, the total lock exposure is 360 seconds — but the locks are sequential, not simultaneous, so each partition is blocked for its own 10 seconds, not for 360 seconds. CREATE INDEX CONCURRENTLY on the parent creates indexes on all partitions concurrently without exclusive locks, using the concurrent mechanism for each partition.
5. Partition count ceiling and evolution policy
Document the maximum partition count at which the chosen granularity remains operationally manageable, the triggers for changing partition granularity, and the procedure for repartitioning when the strategy needs to change.
The partition count ceiling is the point at which the operational overhead of the partition maintenance model exceeds its value: the cron job creates partitions that are too numerous to monitor individually, the statistics collection I/O is material, the cross-partition query overhead is too high for the non-pruning query SLO, or the query plan generation time for a query against 200+ partitions is itself a latency component. Postgres 14 and later improved planner performance for large partition counts, but a table with 500 partitions generates a query plan with 500 nodes for any cross-partition query, and the planning time for that query can exceed 100ms. Document the partition count at which this becomes observable — typically in the range of 100-300 partitions — and the trigger for moving to a coarser granularity (monthly → quarterly, quarterly → annually) or for purging old partitions more aggressively to keep the live partition count within the manageable range.
Repartitioning — changing the partition key or partition type on an existing partitioned table — is the most disruptive evolution scenario. Postgres does not support in-place repartitioning: changing from RANGE on created_at to RANGE on (org_id, created_at) requires creating a new partitioned table, migrating data, and swapping table references. The migration procedure for a large table is a multi-day project and must be executed with careful coordination: a dual-write period where the application writes to both tables, a cutover window where the reference is swapped atomically, and a verification period where query results from the new table are compared against the old. Document the repartitioning procedure in the ADR and include the estimated migration duration at current data volume, so the team has a realistic expectation of the effort required if the partition strategy needs to change. The migration strategy ADR should cross-reference the partitioning ADR for this scenario — the repartitioning migration is the highest-complexity schema migration the system may face.
The performance optimization session that first identifies the need for partitioning is the optimal time to make this decision with full context: the current query patterns are known, the growth rate is measurable, and the team that owns the table is focused on its performance. Retrofitting partitioning 18 months later, when the table has grown to 500GB and the original team has turned over, requires reconstructing the original query patterns and growth projections from scratch — exactly the archaeology problem that decision records are designed to prevent.
Further reading
- Database sharding decision record — sharding distributes partitions across database instances to scale write throughput and storage beyond a single node; partitioning and sharding are complementary, with partitioning providing lifecycle management and query pruning within a shard, and sharding providing cross-instance distribution
- Database vendor decision record — partitioning implementation differs significantly across vendors: Postgres declarative partitioning (11+), MySQL range and hash partitioning with different pruning semantics, TimescaleDB hypertables for time-series workloads; the vendor choice determines which partition types and features are available and how partition pruning interacts with the query planner
- Database migration strategy decision record — converting an existing large table to a partitioned table is one of the most complex schema migrations in production operations; the dual-write migration procedure, the cutover window, and the rollback plan must be specified in the migration strategy, and the partitioning ADR should be written before the migration to guide the key selection decision
- Database read replica decision record — routing cross-partition aggregate queries to read replicas isolates their I/O from the write primary but does not reduce the per-partition overhead; a compliance or analytics query that takes 22 minutes because of 84-partition merge overhead takes 22 minutes on a read replica as well; read replica routing addresses resource competition, not partition count performance
- Database connection pooling decision record — AUTOVACUUM configuration for partitioned tables requires per-partition tuning; the connection pool's idle_in_transaction_session_timeout prevents long-running transactions from blocking VACUUM on hot partitions and causing dead tuple accumulation in the current-period partition
- Transaction isolation decision record — partition pruning interacts with isolation level: a SERIALIZABLE transaction that scans a partitioned table acquires predicate locks on the key ranges it reads, with the lock scope limited to the partitions it accesses; pruning that eliminates 33 of 36 partitions also eliminates the predicate lock overhead for those 33 partitions, making SERIALIZABLE more viable on partitioned tables than on unpartitioned tables of the same total size
- Database backup strategy decision record — logical backups (pg_dump) capture all partitions and their data; detached partitions that have been moved to cold storage tablespaces may not be included in a standard backup if the backup procedure does not explicitly include tablespaces; the backup strategy must account for the full partition lifecycle, including detached and cold-storage partitions that are part of the retention window
- Caching strategy decision record — a cache layer that serves cross-partition aggregate results eliminates the partition merge overhead for repeated queries on the same date range; for compliance reports that run quarterly on fixed date ranges, a cached result eliminates the 22-minute scan entirely on the second and subsequent runs; caching and partitioning together address both the live query performance and the repeat-query cost
- Decisions never written down — the partition key selection rationale, the cross-partition query inventory at the time of partitioning, and the projection of cross-partition query performance at steady-state partition count are made in the initial partitioning session and almost never written down; three years later, the team has 36 partitions and no record of which queries were expected to prune and which were not
- The new CTO onboarding problem — inheriting a partitioned table with 84 partitions, no documentation of the original partition key rationale, and an unexplained 22-minute compliance report that runs quarterly is the canonical data archaeology problem; the partitioning ADR is the document that makes this inheritance tractable without re-running the full performance investigation