<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: R. Ahmad Yasin M.</title>
    <description>The latest articles on DEV Community by R. Ahmad Yasin M. (@raahyama).</description>
    <link>https://dev.to/raahyama</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3950764%2F43ac0e08-9c8f-4b69-b45b-e749e3097712.png</url>
      <title>DEV Community: R. Ahmad Yasin M.</title>
      <link>https://dev.to/raahyama</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/raahyama"/>
    <language>en</language>
    <item>
      <title>Database Partitioning: When, Why, and the Numbers That Matter</title>
      <dc:creator>R. Ahmad Yasin M.</dc:creator>
      <pubDate>Mon, 25 May 2026 15:04:35 +0000</pubDate>
      <link>https://dev.to/raahyama/database-partitioning-when-why-and-the-numbers-that-matter-4oee</link>
      <guid>https://dev.to/raahyama/database-partitioning-when-why-and-the-numbers-that-matter-4oee</guid>
      <description>&lt;h2&gt;
  
  
  1. Introduction
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Partitioning&lt;/strong&gt; splits a large logical table into smaller, independent physical chunks (called &lt;em&gt;partitions&lt;/em&gt;) while keeping a single table interface for your application. Queries and inserts target the single partitioned table; PostgreSQL routes them to the correct child partitions behind the scenes.&lt;/p&gt;

&lt;p&gt;Partitioning improves performance through three mechanisms:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Partition pruning&lt;/strong&gt;: when a &lt;code&gt;WHERE&lt;/code&gt; clause filters on the partition key, PostgreSQL skips scanning partitions that cannot possibly contain matching rows. A query for a specific month on a monthly-partitioned table touches only the relevant partition instead of the entire table.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Smaller per-partition indexes&lt;/strong&gt;: a B-tree index on a large unpartitioned table is deep and expensive to traverse. When the table is split into partitions, each partition gets its own smaller, shallower index, which reduces the B-tree depth and makes index scans faster per partition.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Parallel scans&lt;/strong&gt;: PostgreSQL can scan multiple partitions in parallel when a query spans several of them. A full-year query on a monthly-partitioned table can distribute the work across multiple worker processes.&lt;/li&gt;
&lt;/ul&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Note on terminology:&lt;/strong&gt; PostgreSQL's default table storage is called a "heap" table (unpartitioned, row-based storage). Indexes themselves use B-tree structures. Throughout this post, "unpartitioned" refers to the original heap-organized table, not a heap data structure.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;In PostgreSQL, the most common pattern is &lt;code&gt;PARTITION BY RANGE&lt;/code&gt; on a timestamp column:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;activity_log&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;id&lt;/span&gt;          &lt;span class="nb"&gt;SERIAL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;description&lt;/span&gt; &lt;span class="nb"&gt;TEXT&lt;/span&gt;         &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;started_at&lt;/span&gt;  &lt;span class="n"&gt;TIMESTAMPTZ&lt;/span&gt;  &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;user_id&lt;/span&gt;     &lt;span class="nb"&gt;INTEGER&lt;/span&gt;      &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;...&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;PARTITION&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="k"&gt;RANGE&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;started_at&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You then create child partitions for each time bucket: yearly, monthly, or even daily for extremely high throughput.&lt;/p&gt;




&lt;h2&gt;
  
  
  2. When to Use / When Not To
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Use partitioning when:
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Criterion&lt;/th&gt;
&lt;th&gt;Why&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Table exceeds &lt;strong&gt;1-2 million rows&lt;/strong&gt;
&lt;/td&gt;
&lt;td&gt;Full scans become expensive; indexes bloat&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Data is &lt;strong&gt;time-series / event-driven&lt;/strong&gt;
&lt;/td&gt;
&lt;td&gt;Natural partition key (&lt;code&gt;started_at&lt;/code&gt;, &lt;code&gt;created_at&lt;/code&gt;, &lt;code&gt;datetime&lt;/code&gt;)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Most queries &lt;strong&gt;filter on the partition key&lt;/strong&gt;
&lt;/td&gt;
&lt;td&gt;Enables partition pruning&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;You need &lt;strong&gt;efficient bulk deletes&lt;/strong&gt;
&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;DROP TABLE activity_log_y_2022&lt;/code&gt; is instant vs. &lt;code&gt;DELETE FROM ... WHERE started_at &amp;lt; '2023-01-01'&lt;/code&gt; which bloats the table and runs VACUUM&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h3&gt;
  
  
  Avoid partitioning when:
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Criterion&lt;/th&gt;
&lt;th&gt;Why&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Table is &lt;strong&gt;&amp;lt; 100K rows&lt;/strong&gt;
&lt;/td&gt;
&lt;td&gt;Overhead exceeds benefit&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Queries &lt;strong&gt;span all partitions&lt;/strong&gt; without a date filter&lt;/td&gt;
&lt;td&gt;Every partition gets scanned, often &lt;em&gt;slower&lt;/em&gt; than an unpartitioned table&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Primary lookup is by a &lt;strong&gt;non-partition-key column&lt;/strong&gt; (e.g., FK)&lt;/td&gt;
&lt;td&gt;Partition pruning does nothing; you just pay higher planning time&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Heavy &lt;strong&gt;JOINs&lt;/strong&gt; that cannot push the partition key down&lt;/td&gt;
&lt;td&gt;The planner may end up with a nested loop across every partition&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h3&gt;
  
  
  Quick self-assessment
&lt;/h3&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;"Do my queries always include &lt;code&gt;WHERE &amp;lt;timestamp_column&amp;gt; BETWEEN ...&lt;/code&gt;?"&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;If yes: partition. If the answer is &lt;em&gt;"sometimes"&lt;/em&gt; or &lt;em&gt;"only on reports"&lt;/em&gt;: benchmark first with a copy of production data before committing.&lt;/p&gt;




&lt;h2&gt;
  
  
  3. Benchmark Example
&lt;/h2&gt;

&lt;p&gt;The benchmark example below comes from a real-world Django + PostgreSQL application. It uses the generic table names &lt;code&gt;activity_log&lt;/code&gt; (a log of user activities) and &lt;code&gt;audit_log&lt;/code&gt; (a record of status changes).&lt;/p&gt;

&lt;h3&gt;
  
  
  3.1 Setup
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Test data&lt;/strong&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;200 users, 500 applicants, 100 job openings, and other supporting FK tables&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;activity_log_original&lt;/code&gt;: 2 million rows spanning 2022-2026&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;audit_log_original&lt;/code&gt;: 2 million rows covering the same date range&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Partitioned variants&lt;/strong&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Yearly&lt;/strong&gt;: 5 partitions (&lt;code&gt;y_2022&lt;/code&gt; through &lt;code&gt;y_2026&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Monthly&lt;/strong&gt;: 60 partitions (&lt;code&gt;m_2022_01&lt;/code&gt; through &lt;code&gt;m_2026_12&lt;/code&gt;)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Both variants copy the same 2M rows from the unpartitioned table, so we compare identical data.&lt;/p&gt;

&lt;h3&gt;
  
  
  3.2 Queries Tested
&lt;/h3&gt;

&lt;p&gt;We measure three variants &lt;strong&gt;UNPARTITIONED&lt;/strong&gt; (baseline), &lt;strong&gt;YEARLY&lt;/strong&gt; (5 partitions), &lt;strong&gt;MONTHLY&lt;/strong&gt; (60 partitions) across eight query patterns:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Query&lt;/th&gt;
&lt;th&gt;Pattern&lt;/th&gt;
&lt;th&gt;What it tests&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Q1&lt;/td&gt;
&lt;td&gt;&lt;code&gt;WHERE started_at BETWEEN '2024-03-01' AND '2024-04-01'&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Single month: ideal pruning case&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Q2&lt;/td&gt;
&lt;td&gt;&lt;code&gt;WHERE started_at BETWEEN '2024-01-01' AND '2025-01-01'&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Full year: scans multiple partitions&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Q3&lt;/td&gt;
&lt;td&gt;Q1 + &lt;code&gt;AND user_id = 42 AND status_id IN (2,3)&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Month filter + FK + status lookup&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Q4&lt;/td&gt;
&lt;td&gt;Aggregation &lt;code&gt;GROUP BY month&lt;/code&gt; over 6 months&lt;/td&gt;
&lt;td&gt;Reporting queries&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Q5&lt;/td&gt;
&lt;td&gt;&lt;code&gt;WHERE user_id = 42 ORDER BY started_at DESC LIMIT 50&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Pagination &lt;strong&gt;without&lt;/strong&gt; date filter&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;A1&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;WHERE log_id = 12345&lt;/code&gt; (FK lookup on &lt;code&gt;audit_log&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;Negative control: no date filter&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;A2&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;WHERE datetime BETWEEN ...&lt;/code&gt; on &lt;code&gt;audit_log&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Date range on the "wrong" table&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;A3&lt;/td&gt;
&lt;td&gt;A2 + &lt;code&gt;AND log_id = 12345&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Combined date + FK&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Timings are collected via &lt;code&gt;EXPLAIN (ANALYZE, BUFFERS)&lt;/code&gt;, run 10 times per query, and reported as mean plus-minus standard deviation.&lt;/p&gt;

&lt;h3&gt;
  
  
  3.3 Results
&lt;/h3&gt;

&lt;p&gt;Numbers below are from &lt;strong&gt;10 iterations&lt;/strong&gt; on a local PostgreSQL 16 instance with 2M rows per table (warm cache). Execution times shown as &lt;em&gt;mean plus-minus std&lt;/em&gt; across runs.&lt;/p&gt;

&lt;h4&gt;
  
  
  activity_log: queries that filter on &lt;code&gt;started_at&lt;/code&gt;
&lt;/h4&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Query&lt;/th&gt;
&lt;th&gt;Variant&lt;/th&gt;
&lt;th&gt;Plan&lt;/th&gt;
&lt;th&gt;Execution (mean plus-minus std)&lt;/th&gt;
&lt;th&gt;Planning (mean)&lt;/th&gt;
&lt;th&gt;Speedup vs. Unpartitioned&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;strong&gt;Q1&lt;/strong&gt; Single month&lt;/td&gt;
&lt;td&gt;UNPARTITIONED&lt;/td&gt;
&lt;td&gt;Bitmap Scan&lt;/td&gt;
&lt;td&gt;255.01 plus-minus 112.11 ms&lt;/td&gt;
&lt;td&gt;1.30 ms&lt;/td&gt;
&lt;td&gt;1x&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;YEARLY&lt;/td&gt;
&lt;td&gt;Bitmap Scan&lt;/td&gt;
&lt;td&gt;83.21 plus-minus 44.83 ms&lt;/td&gt;
&lt;td&gt;1.26 ms&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;3.1x&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;MONTHLY&lt;/td&gt;
&lt;td&gt;Seq Scan&lt;/td&gt;
&lt;td&gt;15.88 plus-minus 9.95 ms&lt;/td&gt;
&lt;td&gt;1.81 ms&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;16.1x&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;strong&gt;Q2&lt;/strong&gt; Full year&lt;/td&gt;
&lt;td&gt;UNPARTITIONED&lt;/td&gt;
&lt;td&gt;Bitmap Scan&lt;/td&gt;
&lt;td&gt;453.69 plus-minus 244.94 ms&lt;/td&gt;
&lt;td&gt;0.13 ms&lt;/td&gt;
&lt;td&gt;1x&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;YEARLY&lt;/td&gt;
&lt;td&gt;Seq Scan&lt;/td&gt;
&lt;td&gt;183.74 plus-minus 97.71 ms&lt;/td&gt;
&lt;td&gt;0.49 ms&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;2.5x&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;MONTHLY&lt;/td&gt;
&lt;td&gt;Append (12 partitions)&lt;/td&gt;
&lt;td&gt;229.76 plus-minus 132.12 ms&lt;/td&gt;
&lt;td&gt;7.89 ms&lt;/td&gt;
&lt;td&gt;2.0x&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;strong&gt;Q3&lt;/strong&gt; Month + FK + Status&lt;/td&gt;
&lt;td&gt;UNPARTITIONED&lt;/td&gt;
&lt;td&gt;Bitmap Scan&lt;/td&gt;
&lt;td&gt;4.97 plus-minus 1.30 ms&lt;/td&gt;
&lt;td&gt;0.41 ms&lt;/td&gt;
&lt;td&gt;1x&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;YEARLY&lt;/td&gt;
&lt;td&gt;Bitmap Scan&lt;/td&gt;
&lt;td&gt;2.48 plus-minus 1.00 ms&lt;/td&gt;
&lt;td&gt;0.26 ms&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;2.0x&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;MONTHLY&lt;/td&gt;
&lt;td&gt;Bitmap Scan&lt;/td&gt;
&lt;td&gt;0.12 plus-minus 0.05 ms&lt;/td&gt;
&lt;td&gt;0.25 ms&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;40.9x&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;strong&gt;Q4&lt;/strong&gt; Aggregation (6 months)&lt;/td&gt;
&lt;td&gt;UNPARTITIONED&lt;/td&gt;
&lt;td&gt;GroupAggregate&lt;/td&gt;
&lt;td&gt;318.37 plus-minus 314.19 ms&lt;/td&gt;
&lt;td&gt;0.18 ms&lt;/td&gt;
&lt;td&gt;1x&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;YEARLY&lt;/td&gt;
&lt;td&gt;GroupAggregate&lt;/td&gt;
&lt;td&gt;235.82 plus-minus 134.10 ms&lt;/td&gt;
&lt;td&gt;0.25 ms&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;1.4x&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;MONTHLY&lt;/td&gt;
&lt;td&gt;GroupAggregate&lt;/td&gt;
&lt;td&gt;274.39 plus-minus 171.96 ms&lt;/td&gt;
&lt;td&gt;0.69 ms&lt;/td&gt;
&lt;td&gt;1.2x&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;strong&gt;Q5&lt;/strong&gt; LIMIT 50, no date filter&lt;/td&gt;
&lt;td&gt;UNPARTITIONED&lt;/td&gt;
&lt;td&gt;Limit&lt;/td&gt;
&lt;td&gt;200.38 plus-minus 80.25 ms&lt;/td&gt;
&lt;td&gt;0.20 ms&lt;/td&gt;
&lt;td&gt;1x&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;YEARLY&lt;/td&gt;
&lt;td&gt;Limit&lt;/td&gt;
&lt;td&gt;70.59 plus-minus 32.66 ms&lt;/td&gt;
&lt;td&gt;1.99 ms&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;2.8x&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;MONTHLY&lt;/td&gt;
&lt;td&gt;Limit&lt;/td&gt;
&lt;td&gt;47.68 plus-minus 26.70 ms&lt;/td&gt;
&lt;td&gt;24.84 ms&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;4.2x&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Key takeaways for &lt;code&gt;activity_log&lt;/code&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Q1&lt;/strong&gt; gets a &lt;strong&gt;16x improvement&lt;/strong&gt; with monthly partitioning. The query scans only the &lt;code&gt;m_2024_03&lt;/code&gt; partition instead of the full 2M-row table.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Q3&lt;/strong&gt; shows the strongest win at &lt;strong&gt;41x faster&lt;/strong&gt;. The date filter prunes to a single partition, then the compound index on &lt;code&gt;(status_id, started_at)&lt;/code&gt; resolves the remaining filters locally. This is the ideal scenario for partitioning.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Q2&lt;/strong&gt; (full year) benefits most from &lt;strong&gt;yearly&lt;/strong&gt; partitioning (2.5x). Monthly is slightly worse here because PostgreSQL must &lt;code&gt;Append&lt;/code&gt; across 12 child partitions, and the planning time jumps from 0.13 ms to 7.89 ms.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Q5&lt;/strong&gt; (no date filter) is a cautionary tale: monthly execution is faster (4.2x), &lt;strong&gt;but&lt;/strong&gt; planning time skyrockets from 0.20 ms to &lt;strong&gt;24.84 ms&lt;/strong&gt; (124x worse). If this query runs frequently, the total per-request cost is higher with partitions despite the faster execution.&lt;/li&gt;
&lt;/ul&gt;

&lt;h4&gt;
  
  
  audit_log: the negative control
&lt;/h4&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Query&lt;/th&gt;
&lt;th&gt;Variant&lt;/th&gt;
&lt;th&gt;Plan&lt;/th&gt;
&lt;th&gt;Execution (mean plus-minus std)&lt;/th&gt;
&lt;th&gt;Planning (mean)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;strong&gt;A1&lt;/strong&gt; FK lookup&lt;/td&gt;
&lt;td&gt;UNPARTITIONED&lt;/td&gt;
&lt;td&gt;Sort&lt;/td&gt;
&lt;td&gt;0.07 plus-minus 0.01 ms&lt;/td&gt;
&lt;td&gt;0.32 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;YEARLY&lt;/td&gt;
&lt;td&gt;Sort&lt;/td&gt;
&lt;td&gt;0.24 plus-minus 0.16 ms&lt;/td&gt;
&lt;td&gt;1.54 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;MONTHLY&lt;/td&gt;
&lt;td&gt;Sort&lt;/td&gt;
&lt;td&gt;2.68 plus-minus 3.14 ms&lt;/td&gt;
&lt;td&gt;18.43 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;strong&gt;A2&lt;/strong&gt; Date range&lt;/td&gt;
&lt;td&gt;UNPARTITIONED&lt;/td&gt;
&lt;td&gt;Bitmap Scan&lt;/td&gt;
&lt;td&gt;159.18 plus-minus 66.39 ms&lt;/td&gt;
&lt;td&gt;0.12 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;YEARLY&lt;/td&gt;
&lt;td&gt;Bitmap Scan&lt;/td&gt;
&lt;td&gt;50.25 plus-minus 24.59 ms&lt;/td&gt;
&lt;td&gt;0.16 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;MONTHLY&lt;/td&gt;
&lt;td&gt;Seq Scan&lt;/td&gt;
&lt;td&gt;13.70 plus-minus 9.59 ms&lt;/td&gt;
&lt;td&gt;0.24 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;strong&gt;A3&lt;/strong&gt; Date + FK&lt;/td&gt;
&lt;td&gt;UNPARTITIONED&lt;/td&gt;
&lt;td&gt;Index Scan&lt;/td&gt;
&lt;td&gt;0.07 plus-minus 0.04 ms&lt;/td&gt;
&lt;td&gt;0.11 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;YEARLY&lt;/td&gt;
&lt;td&gt;Index Scan&lt;/td&gt;
&lt;td&gt;0.05 plus-minus 0.01 ms&lt;/td&gt;
&lt;td&gt;0.21 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;MONTHLY&lt;/td&gt;
&lt;td&gt;Index Scan&lt;/td&gt;
&lt;td&gt;0.04 plus-minus 0.01 ms&lt;/td&gt;
&lt;td&gt;0.20 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Key takeaways for &lt;code&gt;audit_log&lt;/code&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;A1&lt;/strong&gt; (FK lookup on &lt;code&gt;log_id&lt;/code&gt;) demonstrates the cost of unnecessary partitioning. Monthly execution is &lt;strong&gt;38x slower&lt;/strong&gt; (2.68 ms vs. 0.07 ms) and planning time is &lt;strong&gt;58x worse&lt;/strong&gt; (18.43 ms vs. 0.32 ms). The planner must consult all 60 partitions to find the matching row. If your application queries by FK alone, partitioning actively degrades performance.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A2&lt;/strong&gt; shows pruning &lt;em&gt;does&lt;/em&gt; help for date-range queries (monthly is 11.6x faster), but this query pattern does not exist in the application. The important lesson: &lt;strong&gt;partition on the column your application actually filters by&lt;/strong&gt;, not just any timestamp column that happens to exist on the table.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  3.4 Running Your Own Benchmark
&lt;/h3&gt;

&lt;p&gt;To reproduce this approach on your own database:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Set up test data&lt;/strong&gt;: create a dedicated benchmark database, populate your target table with several million rows of realistic timestamps, and add the indexes your application uses.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Create partitioned variants&lt;/strong&gt;: build yearly and monthly-range-partitioned copies of the same data so you compare identical rows. Let PostgreSQL &lt;code&gt;ANALYZE&lt;/code&gt; all variants.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Run EXPLAIN ANALYZE&lt;/strong&gt;: execute your actual query patterns against each variant (unpartitioned, yearly, monthly), collect timing data across multiple iterations, and compute mean and standard deviation.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Averaging across runs (e.g., 5-10 iterations) gives you statistically meaningful numbers and reveals whether observed differences are real or just cache warm-up noise.&lt;/p&gt;




&lt;h2&gt;
  
  
  4. Limitations
&lt;/h2&gt;

&lt;h3&gt;
  
  
  4.1 Maintenance Overhead
&lt;/h3&gt;

&lt;p&gt;Partitions do not create themselves. You need:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;New partitions ahead of time&lt;/strong&gt;: if a new month arrives and the partition does not exist yet, inserts fail with &lt;em&gt;"no partition of relation found"&lt;/em&gt;. Automate this with a cron job or a management command that creates partitions 3 months ahead.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;VACUUM across all partitions&lt;/strong&gt;: autovacuum must visit every partition independently. The more partitions, the more vacuum workers compete for resources.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Index sprawl&lt;/strong&gt;: each partition gets its own copy of every index. &lt;em&gt;N&lt;/em&gt; partitions times &lt;em&gt;M&lt;/em&gt; indexes means &lt;em&gt;N x M&lt;/em&gt; B-tree structures to maintain, checkpoint, and vacuum.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  4.2 Partition Key Restrictions
&lt;/h3&gt;

&lt;p&gt;PostgreSQL enforces strict rules on partitioned tables:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;PRIMARY KEY must include the partition key.&lt;/strong&gt; A simple &lt;code&gt;PRIMARY KEY (id)&lt;/code&gt; is &lt;em&gt;invalid&lt;/em&gt; on a table partitioned by &lt;code&gt;started_at&lt;/code&gt;. You must use &lt;code&gt;PRIMARY KEY (id, started_at)&lt;/code&gt;. This breaks any foreign key that references only &lt;code&gt;id&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;UNIQUE constraints&lt;/strong&gt; likewise need the partition key. No single-column unique constraint without including the partition key.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Foreign Keys from child tables to a partitioned parent&lt;/strong&gt; work, but &lt;strong&gt;FKs from a partitioned table to another table require the partition key&lt;/strong&gt; in the FK columns unless the referenced table is also partitioned on the same key.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;In practice, this means you may need to &lt;strong&gt;drop FK constraints at the SQL level&lt;/strong&gt; and enforce referential integrity in the application layer (e.g., Django's &lt;code&gt;on_delete=CASCADE&lt;/code&gt; operates through the ORM's collector, not database-level CASCADE).&lt;/p&gt;

&lt;h3&gt;
  
  
  4.3 Application-Level Bottlenecks
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;ORM-generated queries without date filters&lt;/strong&gt;: if a view runs &lt;code&gt;logs = ActivityLog.objects.filter(user=request.user)&lt;/code&gt;, the ORM will not add a date filter unless you do. This triggers an &lt;code&gt;Append&lt;/code&gt; across every partition, often slower than the original unpartitioned table.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Planning time increases&lt;/strong&gt; with partition count. In our benchmark, unpartitioned planning was 0.32 ms for audit queries; monthly (60 partitions) hit &lt;strong&gt;18.43 ms&lt;/strong&gt; on A1, a 58x increase. For OLTP workloads doing thousands of small queries per second, this adds up.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Connection pooling&lt;/strong&gt;: planning time is incurred per query. If your pooler (PgBouncer) uses transaction pooling, the increased planning overhead applies to every statement.&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  5. Conclusion
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Rule of thumb:&lt;/strong&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Partition by &lt;code&gt;RANGE&lt;/code&gt; on the date column when your table exceeds roughly 1M rows &lt;strong&gt;and&lt;/strong&gt; the majority of queries filter on that date column. Use monthly partitions for the best pruning granularity; use yearly if you want fewer partitions and simpler maintenance. &lt;strong&gt;Do not partition a table&lt;/strong&gt; if your primary lookup is by a non-partition-key column or you lack a consistent date filter, the overhead will make things slower, not faster.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;If you are unsure, benchmark. Clone your production table, partition a copy, run &lt;code&gt;EXPLAIN ANALYZE&lt;/code&gt; on your actual query patterns, and let the numbers decide.&lt;/p&gt;

</description>
      <category>database</category>
      <category>performance</category>
      <category>postgres</category>
    </item>
  </channel>
</rss>
