{"id":2890,"date":"2025-08-21T05:46:12","date_gmt":"2025-08-21T05:46:12","guid":{"rendered":"https:\/\/bynatree.com\/?p=2890"},"modified":"2025-08-21T05:46:12","modified_gmt":"2025-08-21T05:46:12","slug":"partitioning-a-large-postgresql-table-with-minimal-downtime-using-logical-replication","status":"publish","type":"post","link":"https:\/\/divaind.com\/ie1\/2025\/08\/21\/partitioning-a-large-postgresql-table-with-minimal-downtime-using-logical-replication\/","title":{"rendered":"Partitioning a Large PostgreSQL Table with Minimal Downtime Using Logical Replication"},"content":{"rendered":"<h2><b>Background (partition migration with logical replication)<\/b><\/h2>\n<p><span style=\"font-weight: 400;\">Large PostgreSQL tables eventually run into performance problems. As rows accumulate, queries slow down, indexes bloat, and retention policies become harder to enforce.<\/span><\/p>\n<p><span style=\"font-weight: 400;\">In one of our projects, we had a table called <\/span><strong>analytics.user_activity<\/strong><span style=\"font-weight: 400;\"> that logged every user action across an application. It had grown to hundreds of millions of rows, and queries filtering by time <strong>(<\/strong><\/span><strong>event_time<\/strong><span style=\"font-weight: 400;\"><strong>)<\/strong> or organization <strong>(<\/strong><\/span><strong>org_id<\/strong><span style=\"font-weight: 400;\"><strong>)<\/strong> were becoming painfully slow.<\/span><\/p>\n<p><span style=\"font-weight: 400;\">Instead of archiving or purging data, we chose to <\/span><b>partition the table by event time<\/b><span style=\"font-weight: 400;\">. But how could we restructure such a massive table <\/span><b>without major downtime<\/b><span style=\"font-weight: 400;\">?<\/span><\/p>\n<p><span style=\"font-weight: 400;\">The solution: <\/span><b>partition migration with logical replication<\/b><span style=\"font-weight: 400;\">.<\/span><\/p>\n<h2><b>Why Partition?<\/b><\/h2>\n<p><span style=\"font-weight: 400;\">Partitioning makes sense for append-only or time-series workloads.<\/span><\/p>\n<p><span style=\"font-weight: 400;\">Benefits:<\/span><\/p>\n<ul>\n<li style=\"font-weight: 400;\" aria-level=\"1\">\n<blockquote><p><b>Faster queries<\/b><span style=\"font-weight: 400;\">: PostgreSQL prunes irrelevant partitions automatically.<\/span><span style=\"font-weight: 400;\"><br \/>\n<\/span><\/p><\/blockquote>\n<\/li>\n<li style=\"font-weight: 400;\" aria-level=\"1\">\n<blockquote><p><b>Simplified retention<\/b><span style=\"font-weight: 400;\">: Drop or detach old partitions instantly.<\/span><span style=\"font-weight: 400;\"><br \/>\n<\/span><\/p><\/blockquote>\n<\/li>\n<li style=\"font-weight: 400;\" aria-level=\"1\">\n<blockquote><p><b>Efficient indexing<\/b><span style=\"font-weight: 400;\">: Smaller indexes per partition.<\/span><span style=\"font-weight: 400;\"><br \/>\n<\/span><\/p><\/blockquote>\n<\/li>\n<li style=\"font-weight: 400;\" aria-level=\"1\">\n<blockquote><p><b>Scalability<\/b><span style=\"font-weight: 400;\">: Maintenance (VACUUM, ANALYZE, index rebuilds) stays lightweight.<\/span><\/p><\/blockquote>\n<\/li>\n<\/ul>\n<h2><b>Challenge: Migrating with Minimal Downtime<\/b><\/h2>\n<p><span style=\"font-weight: 400;\">The biggest problem when retrofitting partitioning onto a large production table is <\/span><b>how to move existing data<\/b><span style=\"font-weight: 400;\">.<\/span><\/p>\n<ul>\n<li style=\"font-weight: 400;\" aria-level=\"1\"><span style=\"font-weight: 400;\">A <\/span><span style=\"font-weight: 400;\">CREATE TABLE \u2026 PARTITION BY \u2026<\/span><span style=\"font-weight: 400;\"> can\u2019t just be applied to an existing table.<\/span><span style=\"font-weight: 400;\"><br \/>\n<\/span><\/li>\n<li style=\"font-weight: 400;\" aria-level=\"1\"><span style=\"font-weight: 400;\">Copying all rows (<\/span><span style=\"font-weight: 400;\">INSERT INTO new SELECT * FROM old<\/span><span style=\"font-weight: 400;\">) can take hours and block writes.<\/span><span style=\"font-weight: 400;\"><br \/>\n<\/span><\/li>\n<li style=\"font-weight: 400;\" aria-level=\"1\"><span style=\"font-weight: 400;\">If partitions are created incorrectly, new data can flow into the <\/span><b>default partition<\/b><span style=\"font-weight: 400;\">, breaking retention and performance.<\/span><span style=\"font-weight: 400;\">\n<p><\/span><\/li>\n<\/ul>\n<p><span style=\"font-weight: 400;\">That\u2019s where <\/span><b>logical replication<\/b><span style=\"font-weight: 400;\"> comes in. It allows you to replicate data from the old table into the new partitioned one, while keeping them in sync until cutover.<\/span><\/p>\n<h2><b>Migration Strategy<\/b><\/h2>\n<ol>\n<li style=\"font-weight: 400;\" aria-level=\"1\"><b>Keep the old monolithic table running<\/b><span style=\"font-weight: 400;\"> while building a new partitioned version.<\/span><span style=\"font-weight: 400;\"><br \/>\n<\/span><\/li>\n<li style=\"font-weight: 400;\" aria-level=\"1\"><b>Set up logical replication<\/b><span style=\"font-weight: 400;\"> to stream changes from old to new.<\/span><span style=\"font-weight: 400;\"><br \/>\n<\/span><\/li>\n<li style=\"font-weight: 400;\" aria-level=\"1\"><b>Backfill existing data<\/b><span style=\"font-weight: 400;\"> into the partitioned structure.<\/span><span style=\"font-weight: 400;\"><br \/>\n<\/span><\/li>\n<li style=\"font-weight: 400;\" aria-level=\"1\"><b>Cut over applications<\/b><span style=\"font-weight: 400;\"> to use the new table with minimal downtime.<\/span><span style=\"font-weight: 400;\">\n<p><\/span><\/li>\n<\/ol>\n<h2><b>Step-by-Step Migration<\/b><\/h2>\n<h3><b>1. Install <\/b><b>pg_partman<\/b><\/h3>\n<p><span style=\"font-weight: 400;\">We use <\/span><span style=\"font-weight: 400;\">pg_partman<\/span><span style=\"font-weight: 400;\"> to automate partition creation and retention policies.<\/span><\/p>\n<pre class=\"theme:solarized-light lang:pgsql decode:true\">CREATE SCHEMA partman;\nCREATE EXTENSION pg_partman SCHEMA partman;<\/pre>\n<h3><b>2. Create Partitioned Table<\/b><\/h3>\n<pre class=\"theme:solarized-light lang:pgsql decode:true\">CREATE TABLE analytics.user_activity (\n\u00a0\u00a0\u00a0\u00a0activity_id bigserial PRIMARY KEY,\n\u00a0\u00a0\u00a0\u00a0user_id bigint NOT NULL,\n\u00a0\u00a0\u00a0\u00a0org_id bigint NOT NULL,\n\u00a0\u00a0\u00a0\u00a0action text NOT NULL,\n\u00a0\u00a0\u00a0\u00a0event_time timestamptz NOT NULL,\n\u00a0\u00a0\u00a0\u00a0source text,\n\u00a0\u00a0\u00a0\u00a0payload jsonb,\n\u00a0\u00a0\u00a0\u00a0inserted_at timestamptz DEFAULT now()\n) PARTITION BY RANGE (event_time);<\/pre>\n<h3><b>3. Configure <\/b><b>pg_partman<\/b><\/h3>\n<p><span style=\"font-weight: 400;\">Find the earliest timestamp in the old table:<\/span><\/p>\n<pre class=\"theme:solarized-light lang:pgsql decode:true\">SELECT MIN(event_time) FROM old_user_activity;<\/pre>\n<p><span style=\"font-weight: 400;\">Start partitions at that timestamp:<\/span><\/p>\n<pre class=\"theme:solarized-light lang:pgsql decode:true\">SELECT partman.create_parent(\n\u00a0\u00a0\u00a0\u00a0p_parent_table\u00a0 \u00a0 := 'analytics.user_activity',\n\u00a0\u00a0\u00a0\u00a0p_control \u00a0 \u00a0 \u00a0 \u00a0 := 'event_time',\n\u00a0\u00a0\u00a0\u00a0p_type\u00a0 \u00a0 \u00a0 \u00a0 \u00a0 \u00a0 := 'range',\n\u00a0\u00a0\u00a0\u00a0p_interval\u00a0 \u00a0 \u00a0 \u00a0 := '1 day',\n\u00a0\u00a0\u00a0\u00a0p_premake \u00a0 \u00a0 \u00a0 \u00a0 := 14,\n\u00a0\u00a0\u00a0\u00a0p_start_partition := '2025-06-01'\n);<\/pre>\n<p><span style=\"font-weight: 400;\">Pre-generate historical partitions:<\/span><\/p>\n<pre class=\"theme:solarized-light lang:pgsql decode:true\">SELECT partman.create_partition_time(\n\u00a0\u00a0\u00a0\u00a0'analytics.user_activity',\n\u00a0\u00a0\u00a0\u00a0'2025-06-01',\n\u00a0\u00a0\u00a0\u00a0(now()::date + interval '30 days')::date::text\n);<\/pre>\n<h3><b>4. Set Up Logical Replication<\/b><\/h3>\n<p><span style=\"font-weight: 400;\">On <\/span><b>source (old table DB)<\/b><span style=\"font-weight: 400;\">:<\/span><\/p>\n<pre class=\"theme:solarized-light lang:pgsql decode:true\">CREATE PUBLICATION pub_user_activity FOR TABLE old_user_activity;<\/pre>\n<p><span style=\"font-weight: 400;\">On <\/span><b>target (new partitioned DB)<\/b><span style=\"font-weight: 400;\">:<\/span><\/p>\n<pre class=\"theme:solarized-light lang:pgsql decode:true\">CREATE SUBSCRIPTION sub_user_activity\nCONNECTION 'host=source-db port=5432 dbname=mydb user=replicator password=xxxx'\nPUBLICATION pub_user_activity;<\/pre>\n<p><span style=\"font-weight: 400;\">Now inserts\/updates\/deletes from <\/span><span style=\"font-weight: 400;\">old_user_activity<\/span><span style=\"font-weight: 400;\"> will stream into <\/span><span style=\"font-weight: 400;\">analytics.user_activity<\/span><span style=\"font-weight: 400;\">.<\/span><\/p>\n<h3><b>5. Recreate Indexes<\/b><\/h3>\n<p><span style=\"font-weight: 400;\">Indexes must be applied at the parent level, so <\/span><span style=\"font-weight: 400;\">pg_partman<\/span><span style=\"font-weight: 400;\"> propagates them to new partitions:<\/span><\/p>\n<pre class=\"theme:solarized-light lang:pgsql decode:true\">CREATE INDEX idx_activity_org_time ON analytics.user_activity (org_id, event_time);\nCREATE INDEX idx_activity_user_action ON analytics.user_activity (user_id, action);\nCREATE INDEX idx_activity_payload_attr ON analytics.user_activity ((payload-&gt;&gt;'session_id'));<\/pre>\n<p><span style=\"font-weight: 400;\">Apply them to historical partitions too:<\/span><\/p>\n<pre class=\"theme:solarized-light lang:pgsql decode:true\">SELECT partman.apply_indexes('analytics.user_activity');<\/pre>\n<h3><b>6. Validate<\/b><\/h3>\n<ul>\n<li style=\"font-weight: 400;\" aria-level=\"1\"><span style=\"font-weight: 400;\">Compare row counts:<\/span><span style=\"font-weight: 400;\"><br \/>\n<\/span><\/li>\n<\/ul>\n<pre class=\"theme:solarized-light lang:pgsql decode:true\">SELECT (SELECT count(*) FROM old_user_activity) AS old_count, (SELECT count(*) FROM analytics.user_activity) AS new_count;<\/pre>\n<ul>\n<li style=\"font-weight: 400;\" aria-level=\"1\"><span style=\"font-weight: 400;\">Check partition distribution:<\/span><span style=\"font-weight: 400;\"><br \/>\n<\/span><\/li>\n<\/ul>\n<pre class=\"theme:solarized-light lang:pgsql decode:true\">\\dt+ analytics.user_activity*<\/pre>\n<ul>\n<li style=\"font-weight: 400;\" aria-level=\"1\"><span style=\"font-weight: 400;\">Test future inserts:<\/span><span style=\"font-weight: 400;\"><br \/>\n<\/span><\/li>\n<\/ul>\n<pre class=\"theme:solarized-light lang:pgsql decode:true \">INSERT INTO analytics.user_activity (user_id, org_id, action, event_time, source)\nVALUES (12345, 77, 'login', '2025-08-21 00:05:00+00', 'web');<\/pre>\n<pre class=\"theme:solarized-light lang:pgsql decode:true\">SELECT * FROM analytics.user_activity_p20250821;<\/pre>\n<h2><b>Retention with Detached Partitions<\/b><\/h2>\n<p><span style=\"font-weight: 400;\">Instead of dropping, we configured Partman to <\/span><b>detach<\/b><span style=\"font-weight: 400;\"> partitions older than 90 days. This keeps them queryable for audits but excludes them from normal queries:<\/span><\/p>\n<pre class=\"theme:solarized-light lang:pgsql decode:true\">UPDATE partman.part_config\n\u00a0\u00a0\u00a0SET retention = '90 days',\n\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0retention_keep_table = true\n\u00a0WHERE parent_table = 'analytics.user_activity';<\/pre>\n<pre class=\"theme:solarized-light lang:pgsql decode:true\">SELECT partman.run_maintenance('analytics.user_activity');<\/pre>\n<h2><b>Lessons Learned<\/b><\/h2>\n<ul>\n<li style=\"font-weight: 400;\" aria-level=\"1\">\n<blockquote><p><span style=\"font-weight: 400;\">Always check the <\/span><b>earliest event_time<\/b><span style=\"font-weight: 400;\"> before creating partitions.<\/span><span style=\"font-weight: 400;\"><br \/>\n<\/span><\/p><\/blockquote>\n<\/li>\n<li style=\"font-weight: 400;\" aria-level=\"1\">\n<blockquote><p><span style=\"font-weight: 400;\">Use logical replication to avoid downtime during migration.<\/span><span style=\"font-weight: 400;\"><br \/>\n<\/span><\/p><\/blockquote>\n<\/li>\n<li style=\"font-weight: 400;\" aria-level=\"1\">\n<blockquote><p><span style=\"font-weight: 400;\">Create indexes on the parent table \u2014 let <\/span><span style=\"font-weight: 400;\">pg_partman<\/span><span style=\"font-weight: 400;\"> propagate them.<\/span><span style=\"font-weight: 400;\"><br \/>\n<\/span><\/p><\/blockquote>\n<\/li>\n<li style=\"font-weight: 400;\" aria-level=\"1\">\n<blockquote><p><span style=\"font-weight: 400;\">Detached partitions provide flexible retention without data loss.<\/span><\/p><\/blockquote>\n<p><span style=\"font-weight: 400;\"><\/p>\n<p><\/span><\/li>\n<\/ul>\n<h2><b>Conclusion<\/b><\/h2>\n<blockquote><p><span style=\"font-weight: 400;\">By combining <\/span><b>logical replication<\/b><span style=\"font-weight: 400;\"> with <\/span><b>pg_partman<\/b><span style=\"font-weight: 400;\">, we migrated <\/span><strong>analytics.user_activity<\/strong><span style=\"font-weight: 400;\"> \u2014 a massive time-series style table \u2014 into a partitioned structure with <\/span><b>near-zero downtime<\/b><span style=\"font-weight: 400;\">.<\/span><\/p>\n<p><span style=\"font-weight: 400;\">The result: queries sped up, retention became trivial, and operational overhead dropped significantly.<\/span><\/p>\n<p><span style=\"font-weight: 400;\">Partitioning isn\u2019t just for greenfield projects \u2014 with the right approach, you can retrofit it onto existing production workloads safely.<\/span><\/p><\/blockquote>\n<p>&nbsp;<\/p>\n","protected":false},"excerpt":{"rendered":"<p>Background (partition migration with logical replication) Large PostgreSQL tables eventually run into performance problems. As rows accumulate, queries slow down, indexes bloat, and retention policies become harder to enforce. In one of our projects, we had a table called analytics.user_activity that logged every user action across an application. It had grown to hundreds of millions&hellip;<\/p>\n","protected":false},"author":1,"featured_media":2891,"comment_status":"open","ping_status":"open","sticky":false,"template":"","format":"standard","meta":{"footnotes":""},"categories":[28],"tags":[59,86,122,165,195,198,291],"class_list":["post-2890","post","type-post","status-publish","format-standard","has-post-thumbnail","hentry","category-postgresql","tag-techblog","tag-bynatreeinsights","tag-databasearchitecture","tag-infraengineering","tag-logicalreplication","tag-minimaldowntime","tag-postgresqlpartitioning","category-28","description-off"],"aioseo_notices":[],"_links":{"self":[{"href":"https:\/\/divaind.com\/ie1\/wp-json\/wp\/v2\/posts\/2890","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/divaind.com\/ie1\/wp-json\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/divaind.com\/ie1\/wp-json\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/divaind.com\/ie1\/wp-json\/wp\/v2\/users\/1"}],"replies":[{"embeddable":true,"href":"https:\/\/divaind.com\/ie1\/wp-json\/wp\/v2\/comments?post=2890"}],"version-history":[{"count":0,"href":"https:\/\/divaind.com\/ie1\/wp-json\/wp\/v2\/posts\/2890\/revisions"}],"wp:featuredmedia":[{"embeddable":true,"href":"https:\/\/divaind.com\/ie1\/wp-json\/wp\/v2\/media\/2891"}],"wp:attachment":[{"href":"https:\/\/divaind.com\/ie1\/wp-json\/wp\/v2\/media?parent=2890"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/divaind.com\/ie1\/wp-json\/wp\/v2\/categories?post=2890"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/divaind.com\/ie1\/wp-json\/wp\/v2\/tags?post=2890"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}