<?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: Sanidhya</title>
    <description>The latest articles on DEV Community by Sanidhya (@laydownpipe_24).</description>
    <link>https://dev.to/laydownpipe_24</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%2F3970229%2Fbf54dc80-e2ec-47a0-9239-86dfb22f6560.png</url>
      <title>DEV Community: Sanidhya</title>
      <link>https://dev.to/laydownpipe_24</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/laydownpipe_24"/>
    <language>en</language>
    <item>
      <title>I got tired of writing the same history table boilerplate, so I built a Postgres extension</title>
      <dc:creator>Sanidhya</dc:creator>
      <pubDate>Fri, 05 Jun 2026 17:31:22 +0000</pubDate>
      <link>https://dev.to/laydownpipe_24/i-got-tired-of-writing-the-same-history-table-boilerplate-so-i-built-a-postgres-extension-13m8</link>
      <guid>https://dev.to/laydownpipe_24/i-got-tired-of-writing-the-same-history-table-boilerplate-so-i-built-a-postgres-extension-13m8</guid>
      <description>&lt;p&gt;There's this problem I kept running into - first during my internship, then again on a freelance project I picked up afterward. Both of them used PostgreSQL, and both of them at some point needed an answer to the same question: &lt;em&gt;what did this data look like before?&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Could be an audit log. Could be a client asking "what was the price on the 15th?" Could be a bad deployment that nuked a bunch of rows and now someone needs to recover the state from two hours ago.&lt;/p&gt;

&lt;p&gt;And every single time, the answer involved the same tedious ritual: add &lt;code&gt;created_at&lt;/code&gt; and &lt;code&gt;updated_at&lt;/code&gt; columns, create a separate &lt;code&gt;_history&lt;/code&gt; table that mirrors the original, write a trigger to populate it on every insert, update, and delete, and hope you got the interval boundaries right. On the freelance project I actually got them wrong on the first try - rows were either double-counted or missing entirely at certain timestamps. Had to tear it out and redo it.&lt;/p&gt;

&lt;p&gt;So I finally sat down and built something to handle this properly.&lt;/p&gt;




&lt;h2&gt;
  
  
  What pgtime does
&lt;/h2&gt;

&lt;p&gt;You run this on any existing table:&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;SELECT&lt;/span&gt; &lt;span class="n"&gt;pgtime&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;attach&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'orders'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's it. From that point on, every &lt;code&gt;INSERT&lt;/code&gt;, &lt;code&gt;UPDATE&lt;/code&gt;, and &lt;code&gt;DELETE&lt;/code&gt; on the &lt;code&gt;orders&lt;/code&gt; table is automatically captured in a shadow history table called &lt;code&gt;orders_history&lt;/code&gt;. You don't touch your existing schema, your existing queries don't change, and the tracking just works in the background.&lt;/p&gt;

&lt;p&gt;When you call &lt;code&gt;attach()&lt;/code&gt;, the extension dynamically compiles helper functions matched to your table's exact schema. This means when you need to look at the past, you get clean, native queries with no tedious type casting:&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="c1"&gt;-- Get a full table snapshot as of Jan 15th, 2026&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;orders_as_of&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'2026-01-15 10:00:00+00'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Or if you want to see the full change history of a specific row:&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="c1"&gt;-- Retrieve the full audit trail of order #42&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;price&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;sys_from&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;_pgtime_op&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;orders_history&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;42&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  Why a C trigger and not PL/pgSQL
&lt;/h2&gt;

&lt;p&gt;The trigger that captures row changes is written in C. The honest reason is performance - PL/pgSQL has overhead on every row operation, and for tables with high write throughput that adds up fast. The C trigger runs closer to the metal.&lt;/p&gt;

&lt;p&gt;To back this up, I ran a baseline benchmark on 10,000 operations inside a Postgres 16 container. Even with range-indexing overhead (we use &lt;code&gt;tstzrange&lt;/code&gt; and GiST indexes to make point-in-time scans fast), the C trigger still manages to log:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;~63,700 operations/sec for INSERTs&lt;/li&gt;
&lt;li&gt;~20,100 operations/sec for UPDATEs (which require double writes: closing the old version and inserting the new one)&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  There are SDKs too
&lt;/h2&gt;

&lt;p&gt;I didn't want this to only be accessible from raw SQL, so I built thin wrappers for Node/TypeScript and Python.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;TypeScript:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;Pool&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;pg&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;PgTime&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;pgtime-js&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;pool&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Pool&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;connectionString&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;DATABASE_URL&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;pt&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;PgTime&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;pool&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;pt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;attach&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;orders&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="kr"&gt;interface&lt;/span&gt; &lt;span class="nx"&gt;Order&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;item&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;price&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;snapshot&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;pt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;asOf&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;Order&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;orders&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;2026-01-15T10:00:00Z&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;auditTrail&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;pt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;history&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;orders&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;42&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Python:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;pgtime&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;PgTime&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;psycopg2&lt;/span&gt;

&lt;span class="n"&gt;conn&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;psycopg2&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;connect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;postgres://...&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;pt&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;PgTime&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;conn&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;pt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;attach&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;orders&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;snapshot&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;pt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;as_of&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;orders&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;2026-01-15T10:00:00Z&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;logs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;pt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;history&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;orders&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;42&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And there's a Go CLI for terminal operations:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pgtime attach orders &lt;span class="nt"&gt;--db&lt;/span&gt; postgres://localhost/mydb
pgtime &lt;span class="nb"&gt;history &lt;/span&gt;orders &lt;span class="nt"&gt;--id&lt;/span&gt; 42 &lt;span class="nt"&gt;--db&lt;/span&gt; postgres://localhost/mydb
pgtime diff orders &lt;span class="nt"&gt;--from&lt;/span&gt; &lt;span class="s2"&gt;"2026-01-01"&lt;/span&gt; &lt;span class="nt"&gt;--to&lt;/span&gt; &lt;span class="s2"&gt;"2026-02-01"&lt;/span&gt; &lt;span class="nt"&gt;--db&lt;/span&gt; postgres://localhost/mydb
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  Where it stands right now
&lt;/h2&gt;

&lt;p&gt;It's &lt;code&gt;v0.1.1-alpha&lt;/code&gt;. Transaction time - meaning the database system clock - is fully working. Valid-time (business time, where &lt;em&gt;you&lt;/em&gt; define the time bounds rather than the system) is on the roadmap for v0.2 but not there yet.&lt;/p&gt;

&lt;p&gt;The SDKs also aren't on npm or PyPI yet, so installation is from a local clone for now. That'll change in the next release. Installation is Docker-based for development and testing, or you can compile the C extension directly if you have the Postgres dev headers - the README walks through both.&lt;/p&gt;




&lt;h2&gt;
  
  
  If you want to check it out
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;GitHub:&lt;/strong&gt; &lt;a href="https://github.com/Sanidhyavijay24/pgtime" rel="noopener noreferrer"&gt;https://github.com/Sanidhyavijay24/pgtime&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;If you've dealt with this problem before - or solved it a different way - I'd genuinely like to hear how. And if something looks wrong or off in the implementation, please say so. Still early days and real feedback is more useful than stars right now.&lt;/p&gt;

</description>
      <category>postgres</category>
      <category>opensource</category>
      <category>sql</category>
      <category>database</category>
    </item>
  </channel>
</rss>
